Skip to content
This repository was archived by the owner on Jan 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dependencies {

// Feign
implementation "org.springframework.cloud:spring-cloud-starter-openfeign:4.1.3"
testImplementation("io.github.openfeign:feign-core")
}

sentry {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ public class RefreshTokenStore {
public void replace(Long memberId, String refresh) {
refreshTokenRepository.deleteAllByMemberId(memberId);
refreshTokenRepository.save(RefreshToken.builder()
.memberId(memberId).
token(refresh)
.memberId(memberId)
.token(refresh)
.build());
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/example/spot/common/config/FeignConfig.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
package com.example.spot.common.config;

import com.example.spot.common.infrastructure.feign.retry.SelectiveRetryErrorDecoder;
import feign.Retryer;
import feign.codec.ErrorDecoder;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@EnableFeignClients(basePackages = "com.example.spot")
@Configuration
public class FeignConfig {

@Bean
public ErrorDecoder errorDecoder() {
return new SelectiveRetryErrorDecoder();
}

@Bean
public Retryer retryer() {
// 초기대기 200ms, 최대대기 800ms, 최대 3번 재시도
return new Retryer.Default(200, 800, 3);
}
Comment on lines +19 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Double-check Feign maxAttempts semantics vs. the comment; adjust either comment or value

In feign.Retryer.Default(period, maxPeriod, maxAttempts), maxAttempts typically represents the total number of attempts (including the initial try), not the number of retries. Your comment says “최대 3번 재시도” which implies 3 retries (i.e., 4 total attempts), while the code sets 3 maxAttempts.

  • If you intend 3 retries: set maxAttempts to 4.
  • If you intend a total of 3 attempts: keep the value and update the comment.

Option A (3 retries → 4 total attempts):

-        // 초기대기 200ms, 최대대기 800ms, 최대 3번 재시도
-        return new Retryer.Default(200, 800, 3);
+        // 초기대기 200ms, 최대대기 800ms, 최대 3번 재시도 (총 4회 시도)
+        return new Retryer.Default(200, 800, 4);

Option B (3 total attempts → fix comment):

-        // 초기대기 200ms, 최대대기 800ms, 최대 3번 재시도
+        // 초기대기 200ms, 최대대기 800ms, 최대 3회 시도 (Feign maxAttempts는 총 시도 수)
         return new Retryer.Default(200, 800, 3);

To confirm semantics for your Feign version:


🌐 Web query:

In OpenFeign's Retryer.Default constructor, does maxAttempts represent total attempts (including the first) or only retries?

💡 Result:

In OpenFeign's Retryer.Default constructor, the maxAttempts parameter represents the total number of attempts, including the initial call and all retries[3]. This means if maxAttempts is set to 3, Feign will make up to three calls in total: the initial attempt plus up to two retries if necessary[3].

Supporting details:

  • The matez blog clarifies in a code example: by setting maxAttempts to 3, the test verifies that Feign makes a total of 3 attempts, not just 3 retries after the initial attempt[3].
  • This is further supported by descriptions from other sources stating that Feign will "retry only a given number of times" and the configuration option is named for "maximum number of attempts," not retries, meaning initial plus retries[1][4].

Key points:

  • maxAttempts = 1: Only the initial call, no retries.
  • maxAttempts = N (>1): Initial call plus up to N-1 retries.

Therefore, for Feign's Retryer.Default, maxAttempts counts total attempts, including the first[3].

Citations:


Fix Feign Retryer.Default comment to reflect total‐attempts semantics

Feign’s maxAttempts parameter counts total attempts (initial call + retries). With maxAttempts = 3, Feign performs up to 3 calls (1 initial + 2 retries). Update the comment in:

• File: src/main/java/com/example/spot/common/config/FeignConfig.java
Lines: 19–23

-        // 초기대기 200ms, 최대대기 800ms, 최대 3번 재시도
+        // 초기대기 200ms, 최대대기 800ms, 총 3회 시도 (1회 호출 + 최대 2회 재시도)
         return new Retryer.Default(200, 800, 3);

If you actually want 3 retries (4 total attempts), change the code instead:

// 초기대기 200ms, 최대대기 800ms, 최대 3회 재시도 (총 4회 시도)
return new Retryer.Default(200, 800, 4);
🤖 Prompt for AI Agents
In src/main/java/com/example/spot/common/config/FeignConfig.java around lines 19
to 23, the comment for Retryer.Default incorrectly describes maxAttempts as
number of retries; update the comment to state that maxAttempts is the total
number of attempts (initial call + retries) so maxAttempts = 3 results in up to
3 calls (1 initial + 2 retries). If the intent was to allow 3 retries (4 total
attempts) instead, change the constructor argument to 4 and adjust the comment
accordingly (e.g., "초기대기 200ms, 최대대기 800ms, 최대 3회 재시도 (총 4회 시도)").

}
14 changes: 0 additions & 14 deletions src/main/java/com/example/spot/common/config/FeignRetryConfig.java

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,25 @@ public static <T> T run(Supplier<T> call) {
try {
return call.get();
} catch (FeignException e) {
String message = e.getMessage() != null ? e.getMessage() : "";
String masked = mask(message);
throw new ExternalApiException(
"Feign API 호출 실패: " + extractMessage(e), e);
"Feign API 호출 실패(" + e.status() + "): " + masked, e
);
}
}

private static String extractMessage(FeignException e) {
return e.responseBody()
.map(body -> new String(body.array()))
.orElse(e.getMessage());
private static String mask(String s) {
if (s == null || s.isEmpty()) {
return s;
}
String out = s;
out = out.replaceAll("(?i)(Authorization)\\s*[:=]\\s*([^\\r\\n]+)", "$1: [REDACTED]");
out = out.replaceAll("(?i)(Set-Cookie|Cookie)\\s*[:=]\\s*([^\\r\\n]+)", "$1: [REDACTED]");
out = out.replaceAll("(?i)(access[_-]?token|id[_-]?token|refresh[_-]?token)\\s*[:=]\\s*([\\w\\.-]+)",
"$1=[REDACTED]");
out = out.replaceAll("(?i)(\"(?:password|pass|secret|token|authorization)\"\\s*:\\s*\")([^\"]+)(\")",
"$1[REDACTED]$3");
return out;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.example.spot.common.infrastructure.feign.retry;

import feign.Response;
import feign.RetryableException;
import feign.codec.ErrorDecoder;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;

public class SelectiveRetryErrorDecoder implements ErrorDecoder {

public static final String GET = "GET";
public static final String HEAD = "HEAD";
public static final String OPTIONS = "OPTIONS";
public static final String RETRYABLE_STATUS = "retryable status ";
public static final String RETRY_AFTER = "Retry-After";
public static final long THRESHOLD = 1000L;

private final ErrorDecoder defaultDecoder = new Default();

@Override
public Exception decode(String methodKey, Response response) {
int status = response.status();

// 재시도 대상 상태코드
boolean retryableStatus = status == 429 || status == 408 || (status >= 500 && status <= 599);

// 멱등 메서드만 재시도 (GET/HEAD/OPTIONS)
String method = response.request().httpMethod().name();
boolean idempotent = method.equals(GET) || method.equals(HEAD) || method.equals(OPTIONS);
if (!(retryableStatus && idempotent)) {
return defaultDecoder.decode(methodKey, response);
}

Long retryAfter = extractRetryAfter(response.headers());
return new RetryableException(status, RETRYABLE_STATUS + status, response.request().httpMethod(),
retryAfter, response.request(), readBody(response), response.headers());
}

private Long extractRetryAfter(Map<String, Collection<String>> headers) {
Collection<String> value = headers.getOrDefault(RETRY_AFTER, Collections.emptyList());
if (value.isEmpty()) {
return null;
}
String v = value.iterator().next().trim();
try {
long seconds = Long.parseLong(v);
return System.currentTimeMillis() + (seconds * THRESHOLD);
} catch (NumberFormatException ignore) {
return null;
}
}

private byte[] readBody(Response response) {
if (response.body() == null) {
return null;
}
try (InputStream in = response.body().asInputStream()) {
return in.readAllBytes();
} catch (IOException e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.example.spot.common.feign;

import static org.assertj.core.api.Assertions.assertThat;

import com.example.spot.common.infrastructure.feign.retry.SelectiveRetryErrorDecoder;
import feign.Request;
import feign.Response;
import feign.RetryableException;
import feign.codec.ErrorDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;

class SelectiveRetryErrorDecoderTest {

private final ErrorDecoder decoder = new SelectiveRetryErrorDecoder();

private Response newResponse(int status, String method, Map<String, Collection<String>> headers, String body) {
Request req = Request.create(
Request.HttpMethod.valueOf(method),
"https://api.test/resource",
Map.of(), // request headers
null, // body
StandardCharsets.UTF_8,
null
);

Response.Builder builder = Response.builder()
.status(status)
.reason("test")
.headers(headers == null ? Map.of() : headers)
.request(req);

if (body != null) {
builder.body(body.getBytes(StandardCharsets.UTF_8));
}

return builder.build();
}

@Test
void GET_500_is_retryable() {
Response res = newResponse(500, "GET", null, "{\"msg\":\"oops\"}");
Exception ex = decoder.decode("key", res);
assertThat(ex).isInstanceOf(RetryableException.class);
}

@Test
void GET_429_with_retryAfter_is_retryable() {
Map<String, Collection<String>> headers = Map.of("Retry-After", List.of("2"));
Response res = newResponse(429, "GET", headers, null);
Exception ex = decoder.decode("key", res);
assertThat(ex).isInstanceOf(RetryableException.class);
}

@Test
void POST_500_is_NOT_retryable() {
Response res = newResponse(500, "POST", null, null);
Exception ex = decoder.decode("key", res);
assertThat(ex).isNotInstanceOf(RetryableException.class);
}

@Test
void GET_400_is_NOT_retryable() {
Response res = newResponse(400, "GET", null, null);
Exception ex = decoder.decode("key", res);
assertThat(ex).isNotInstanceOf(RetryableException.class);
}
}