Skip to content
This repository was archived by the owner on Jan 11, 2026. It is now read-only.

[REFACTOR] Feign 재시도 및 예외 추가 설정#35

Merged
msk226 merged 2 commits intodevelopfrom
SPOT-306/refactor
Aug 19, 2025
Merged

[REFACTOR] Feign 재시도 및 예외 추가 설정#35
msk226 merged 2 commits intodevelopfrom
SPOT-306/refactor

Conversation

@msk226
Copy link
Member

@msk226 msk226 commented Aug 19, 2025

#️⃣ 연관된 이슈


🔎 작업 내용

  • 조건 별 Feign 재시도 로직 추가
    • (5Xx, 429, 408인 경우 && 멱등 시도인 경우)만 재시도
  • SafeFeignExecutor 내 개인정보 임시 마스킹 로직 추가

📷 스크린샷 (선택)

작업한 결과물에 대한 간단한 스크린샷을 올려주세요.


💬리뷰 요구사항 (선택)

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요.

Summary by CodeRabbit

  • New Features
    • Smarter Feign retries for idempotent requests on 429, 408, and 5xx responses with configured backoff.
    • Safer error messages by redacting tokens, cookies, passwords, and authorization data.
  • Refactor
    • Consolidated Feign retry configuration into a single configuration.
  • Tests
    • Added unit tests covering retryable vs. non-retryable scenarios and Retry-After handling.
  • Chores
    • Added a test-scoped Feign dependency.
  • Style
    • Minor formatting cleanup with no functional changes.

@msk226 msk226 self-assigned this Aug 19, 2025
@msk226 msk226 added the ♻️ refactor Code Refactoring label Aug 19, 2025
@msk226 msk226 linked an issue Aug 19, 2025 that may be closed by this pull request
@coderabbitai
Copy link

coderabbitai bot commented Aug 19, 2025

Walkthrough

Introduces selective Feign retry/error-decoding, centralizes retry bean into FeignConfig, adds message masking in SafeFeignExecutor, adds a test-only Feign dependency, formats a method chain, and adds unit tests for the new ErrorDecoder. Removes the legacy FeignRetryConfig.

Changes

Cohort / File(s) Summary of edits
Build setup
build.gradle
Adds test-scoped dependency: testImplementation("io.github.openfeign:feign-core").
Feign config and retry wiring
src/main/java/com/example/spot/common/config/FeignConfig.java, src/main/java/com/example/spot/common/config/FeignRetryConfig.java (deleted), src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java
Registers beans: ErrorDecoder (SelectiveRetryErrorDecoder) and Retryer.Default(200, 800, 3) in FeignConfig; removes separate FeignRetryConfig; adds SelectiveRetryErrorDecoder implementing selective retry on 429/408/5xx for idempotent methods with optional Retry-After.
Feign executor exception masking
src/main/java/com/example/spot/common/infrastructure/feign/SafeFeignExecutor.java
Replaces response-body extraction with sanitized message masking; builds ExternalApiException including HTTP status; adds regex-based mask helper; removes extractMessage helper.
Formatting only
src/main/java/com/example/spot/auth/application/refactor/member/RefreshTokenStore.java
Adjusts method-chaining line breaks; no behavior changes.
Tests
src/test/java/com/example/spot/common/feign/SelectiveRetryErrorDecoderTest.java
Adds unit tests covering retryable/non-retryable decoding across methods/statuses and Retry-After handling.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant FeignClient
  participant RemoteAPI
  participant ErrorDecoder as SelectiveRetryErrorDecoder
  participant Retryer

  Caller->>FeignClient: invoke()
  FeignClient->>RemoteAPI: HTTP request
  alt Success
    RemoteAPI-->>FeignClient: 2xx response
    FeignClient-->>Caller: result
  else Error response
    RemoteAPI-->>FeignClient: 4xx/5xx response
    FeignClient->>ErrorDecoder: decode(methodKey, response)
    alt 429/408/5xx AND idempotent (GET/HEAD/OPTIONS)
      ErrorDecoder-->>FeignClient: RetryableException (+optional Retry-After)
      FeignClient->>Retryer: backoff & retry (max=3)
      Retryer-->>FeignClient: next attempt / give up
    else Non-retryable
      ErrorDecoder-->>FeignClient: Exception (default)
    end
    FeignClient-->>Caller: exception
  end
Loading
sequenceDiagram
  participant Caller
  participant SafeExec as SafeFeignExecutor
  participant FeignClient
  participant Mask as mask()

  Caller->>SafeExec: run(supplier)
  SafeExec->>FeignClient: call
  alt FeignException
    FeignClient-->>SafeExec: throws FeignException (status, message)
    SafeExec->>Mask: sanitize(message)
    Mask-->>SafeExec: masked message
    SafeExec-->>Caller: throws ExternalApiException("Feign API 호출(<status>): <masked>")
  else Other
    FeignClient-->>SafeExec: result/other exception
    SafeExec-->>Caller: result/propagate
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Poem

A hop, a skip, I tweak the wire,
Retries only when safe to retry higher.
I mask the secrets, shh—no peeking!
Tests nibble bugs that once were sneaking.
With feathery paws I tune the flow—
Thump-thump: to prod, I’m ready to go! 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch SPOT-306/refactor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@msk226 msk226 merged commit eee7fcc into develop Aug 19, 2025
1 of 2 checks passed
@msk226 msk226 deleted the SPOT-306/refactor branch August 19, 2025 10:32
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (8)
build.gradle (1)

83-83: Avoid versionless feign-core in tests
The test scope declares io.github.openfeign:feign-core without a version, and there’s no Spring Cloud BOM imported under the dependencyManagement plugin to manage it. Since spring-cloud-starter-openfeign:4.1.3 already brings in Feign transitively, this line is redundant and can break your Gradle build. You have three options:

• Remove the redundant test dependency:

-    testImplementation("io.github.openfeign:feign-core")

• Pin an explicit Feign version that matches Spring Cloud OpenFeign (e.g., 13.3.0):

-    testImplementation("io.github.openfeign:feign-core")
+    testImplementation("io.github.openfeign:feign-core:13.3.0")

• Or import the Spring Cloud BOM so you can keep a versionless declaration:

dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.3"
  }
}

Please verify that your project’s BOM configuration (if any) already manages feign-core before leaving it versionless.

src/main/java/com/example/spot/common/infrastructure/feign/SafeFeignExecutor.java (1)

24-35: Widen masking coverage (tokens with +/ and query params; API keys).

The current regex misses some token shapes (e.g., base64 with + or /) and URL query params. Add API key headers as well.

Apply this diff to broaden redaction:

         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]");
+        // API keys in headers
+        out = out.replaceAll("(?i)(X-Api-Key|Api-Key)\\s*[:=]\\s*([^\\r\\n]+)", "$1: [REDACTED]");
+        // token-like key/value pairs (allow +, /, = and stop at whitespace/quote/semicolon)
+        out = out.replaceAll("(?i)(access[_-]?token|id[_-]?token|refresh[_-]?token)\\s*[:=]\\s*([^\\s\\r\\n;\"']+)",
+                "$1=[REDACTED]");
         out = out.replaceAll("(?i)(\"(?:password|pass|secret|token|authorization)\"\\s*:\\s*\")([^\"]+)(\")",
                 "$1[REDACTED]$3");
+        // query parameters (e.g., ?access_token=... or &token=...)
+        out = out.replaceAll("(?i)([?&](?:access[_-]?token|id[_-]?token|refresh[_-]?token|token|authorization)=)([^&\\s]+)",
+                "$1[REDACTED]");
         return out;
src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java (3)

31-33: Confirm intended idempotent set (PUT/DELETE are spec-idempotent but currently excluded).

By spec, PUT and DELETE are idempotent (though not safe). If your APIs observe idempotency semantics, consider including them; otherwise, keeping the conservative set is fine.

Minimal change if you decide to allow them:

-        boolean idempotent = method.equals(GET) || method.equals(HEAD) || method.equals(OPTIONS);
+        boolean idempotent = method.equals(GET) || method.equals(HEAD) || method.equals(OPTIONS)
+                || method.equals("PUT") || method.equals("DELETE");

42-54: Handle Retry-After case-insensitively and support HTTP-date format.

HTTP header names are case-insensitive, and Retry-After may be an HTTP-date or seconds. Current code only matches exact "Retry-After" and parses integers.

Apply this diff to improve robustness:

-    private Long extractRetryAfter(Map<String, Collection<String>> headers) {
-        Collection<String> value = headers.getOrDefault(RETRY_AFTER, Collections.emptyList());
+    private Long extractRetryAfter(Map<String, Collection<String>> headers) {
+        Collection<String> value = Collections.emptyList();
+        for (Map.Entry<String, Collection<String>> e : headers.entrySet()) {
+            if (RETRY_AFTER.equalsIgnoreCase(e.getKey())) {
+                value = e.getValue();
+                break;
+            }
+        }
         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;
+        } catch (NumberFormatException ignore) {
+            // Try HTTP-date (RFC 7231)
+            try {
+                return java.time.ZonedDateTime.parse(v, java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME)
+                        .toInstant().toEpochMilli();
+            } catch (Exception ignored) {
+                return null;
+            }
         }
     }

No additional imports needed if you inline FQCNs as above. If you prefer imports, add:

import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;

56-65: Optional: avoid buffering large bodies into memory.

If you don’t need the response body for retry logic/logging, you can skip readBody and pass null to reduce memory footprint on large error payloads.

Example:

-        return new RetryableException(status, RETRYABLE_STATUS + status, response.request().httpMethod(),
-                retryAfter, response.request(), readBody(response), response.headers());
+        return new RetryableException(status, RETRYABLE_STATUS + status, response.request().httpMethod(),
+                retryAfter, response.request(), null, response.headers());
src/test/java/com/example/spot/common/feign/SelectiveRetryErrorDecoderTest.java (2)

51-56: Consider asserting retryAfter presence when header provided.

This would validate that Retry-After is parsed and propagated.

Example assertion (adapt to your Feign version’s API):

RetryableException re = (RetryableException) ex;
// e.g., assertThat(re.retryAfter()).isNotNull();  // if available in your Feign version
assertThat(re.getMessage()).contains("retryable status 429");

65-70: Add a few more cases for robustness (optional).

  • 408 (Request Timeout) with GET should be retryable.
  • Retry-After as HTTP-date and with lowercase header name (case-insensitive lookup).
  • HEAD/OPTIONS 5xx should be retryable.
  • If you later include PUT/DELETE as idempotent, add matching tests.

Example additions:

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

@Test
void GET_429_with_httpDate_retryAfter_is_retryable() {
    Map<String, Collection<String>> headers = Map.of("retry-after", List.of("Wed, 21 Oct 2015 07:28:00 GMT"));
    Response res = newResponse(429, "GET", headers, null);
    Exception ex = decoder.decode("key", res);
    assertThat(ex).isInstanceOf(RetryableException.class);
}

@Test
void HEAD_503_is_retryable() {
    Response res = newResponse(503, "HEAD", null, null);
    Exception ex = decoder.decode("key", res);
    assertThat(ex).isInstanceOf(RetryableException.class);
}
src/main/java/com/example/spot/common/config/FeignConfig.java (1)

14-17: Confirm global ErrorDecoder intent and consider guard
Verified there are no other ErrorDecoder beans, no custom @FeignClient(configuration=…), and no duplicate Retryer beans in the codebase—this global decoder is currently the sole implementation. If you intend it to apply project-wide, no further action is required; otherwise, to allow future overrides without bean conflicts, add a conditional guard:

• Location to update:

  • src/main/java/com/example/spot/common/config/FeignConfig.java:14-17

• Optional refactor diff:

 @Configuration
 public class FeignConfig {

-    @Bean
+    @Bean
+    @ConditionalOnMissingBean(ErrorDecoder.class)
     public ErrorDecoder errorDecoder() {
         return new SelectiveRetryErrorDecoder();
     }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 587926a and 019bf8d.

📒 Files selected for processing (7)
  • build.gradle (1 hunks)
  • src/main/java/com/example/spot/auth/application/refactor/member/RefreshTokenStore.java (1 hunks)
  • src/main/java/com/example/spot/common/config/FeignConfig.java (1 hunks)
  • src/main/java/com/example/spot/common/config/FeignRetryConfig.java (0 hunks)
  • src/main/java/com/example/spot/common/infrastructure/feign/SafeFeignExecutor.java (1 hunks)
  • src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java (1 hunks)
  • src/test/java/com/example/spot/common/feign/SelectiveRetryErrorDecoderTest.java (1 hunks)
💤 Files with no reviewable changes (1)
  • src/main/java/com/example/spot/common/config/FeignRetryConfig.java
🧰 Additional context used
🧬 Code Graph Analysis (2)
src/test/java/com/example/spot/common/feign/SelectiveRetryErrorDecoderTest.java (1)
src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java (1)
  • SelectiveRetryErrorDecoder (12-66)
src/main/java/com/example/spot/common/config/FeignConfig.java (1)
src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java (1)
  • SelectiveRetryErrorDecoder (12-66)
🔇 Additional comments (7)
src/main/java/com/example/spot/auth/application/refactor/member/RefreshTokenStore.java (1)

17-19: LGTM: style-only chain formatting.

No behavioral changes; the builder call remains the same. Looks good.

src/main/java/com/example/spot/common/infrastructure/feign/SafeFeignExecutor.java (1)

16-21: Good: centralized masking with status-aware ExternalApiException message.

Catches FeignException, masks sensitive bits, and includes HTTP status in the message. This reduces PII leakage while keeping useful context.

src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java (2)

28-35: Selective retry conditions look good.

  • Retries on 5xx, 429, 408.
  • Restricts to idempotent HTTP methods to avoid unsafe replays.

38-40: Double-check RetryableException constructor compatibility across Feign versions.

You’re passing a Long for retryAfter, which is correct for newer Feign. Older Feign used Date. Ensure your resolved feign-core matches this signature to avoid runtime/linking issues.

To verify locally:

  • Inspect the constructor in your resolved feign-core jar (IDE or gradlew dependencyInsight).
  • If you need broader compatibility, convert to Date when you have a timestamp:
    new Date(retryAfter) (and use the constructor that accepts Date).
src/test/java/com/example/spot/common/feign/SelectiveRetryErrorDecoderTest.java (2)

44-48: Good: covers GET 5xx as retryable.

Solid baseline test ensuring the primary retry condition works.


58-63: Good: ensures non-idempotent method is not retried.

This guards against accidental broadening of retry scope.

src/main/java/com/example/spot/common/config/FeignConfig.java (1)

3-5: LGTM: Centralized Feign retry wiring (imports + bean exposure) looks correct

New imports for SelectiveRetryErrorDecoder, Retryer, and ErrorDecoder are appropriate. Pulling retry/error-decoding into FeignConfig consolidates concerns and removes the need for a separate Retry config class.

Also applies to: 7-7

Comment on lines +19 to +23
@Bean
public Retryer retryer() {
// 초기대기 200ms, 최대대기 800ms, 최대 3번 재시도
return new Retryer.Default(200, 800, 3);
}
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회 시도)").

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

♻️ refactor Code Refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REFACTOR] Feign 재시도 및 예외 추가 설정

1 participant