Skip to content

Introduce RetryLimiter#6409

Merged
jrhee17 merged 6 commits intoline:mainfrom
jrhee17:feat/retry-limiter
Nov 26, 2025
Merged

Introduce RetryLimiter#6409
jrhee17 merged 6 commits intoline:mainfrom
jrhee17:feat/retry-limiter

Conversation

@jrhee17
Copy link
Copy Markdown
Contributor

@jrhee17 jrhee17 commented Sep 23, 2025

Motivation:

This changeset attempts to solve the same problem as #6318.

Retry limiting is a concept which limits the number of retries in case a system undergoes a prolonged period of service degradation.
gRPC offers a token-based configuration which limits retries depending on certain predicates, whereas envoy offers a simple concurrency limiting configuration based on the number of active retries.

To support token-based retry limiters, I propose that RetryDecision#permits is added as metadata which could signal to RetryLimiter whether retries should be further made. This could be useful for systems behind load balancers, as the load balancer may return certain status codes depending on the health upstream.

To support simple concurrency-based retry limiters, I propose that a RetryLimiter#shouldRetry method is called right before a retry is executed.

Modifications:

  • Introduced RetryLimiter which acts as an extension to dynamically limit retries.
    • RetryLimiter#shouldRetry decides whether a retry should be executed
    • RetryLimiter#handleDecision is invoked when a RetryDecision is made. RetryDecision#permits may be used to update the internal state and decide whether retries should be allowed.
  • Added RetryDecision#permits
  • Added APIs so users can set RetryLimiter to RetryConfig

Result:

@jrhee17 jrhee17 added this to the 1.34.0 milestone Sep 23, 2025
@jrhee17 jrhee17 marked this pull request as ready for review September 23, 2025 03:34
Copy link
Copy Markdown
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

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

Overall looks good.

* Returns a {@link RetryLimiter} which limits the number of concurrent retry requests.
* This limiter does not consider {@link RetryDecision#permits()} when limiting retries.
*/
static RetryLimiter concurrencyLimiting(long maxRequests) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional) Would it be useful to support rateLimiting by default?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I forgot to leave a comment on this - the RetryLimiter API doesn't go well with guava's RateLimiter as there is no way to determine if a the state should be rate-limited at a given time.

I'd like to revisit this later on unless I'm missing something/you have a better idea.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think using a rate limiter is also an important way to control retries, but we don’t need to implement it now. We can consider it later as a separate issue.

Comment on lines +73 to +77
if (permits > 0) {
if (currentCount == 0) {
break;
}
newCount = Math.max(currentCount - THREE_DECIMAL_PLACES_SCALE_UP, 0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Javadoc says

each positive {@link RetryDecision#permits()} will increment available tokens by {@code tokenRatio} 

Which is different from the actual logic.
Does the permit always -1, 0, 1?
I'm asking because this logic doesn't take into account the permit numbers, so I was wondering if we could use more specific names than 'permit'.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've generalized tokenBased. Although users who prefer gRPC-style retry limiting will need to add a little more logic, the probably makes more sense for general users.

@codecov
Copy link
Copy Markdown

codecov bot commented Oct 17, 2025

Codecov Report

❌ Patch coverage is 75.22124% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.19%. Comparing base (8150425) to head (1df8269).
⚠️ Report is 248 commits behind head on main.

Files with missing lines Patch % Lines
...m/linecorp/armeria/client/retry/RetryDecision.java 50.00% 10 Missing ⚠️
...p/armeria/client/retry/TokenBasedRetryLimiter.java 65.21% 5 Missing and 3 partials ⚠️
...ria/client/retry/ConcurrencyBasedRetryLimiter.java 66.66% 4 Missing and 1 partial ⚠️
...ecorp/armeria/client/retry/RetryConfigBuilder.java 66.66% 2 Missing ⚠️
...rp/armeria/client/retry/RetryLimitedException.java 80.00% 0 Missing and 1 partial ⚠️
...m/linecorp/armeria/client/retry/RetryLimiters.java 94.11% 1 Missing ⚠️
...necorp/armeria/client/retry/RetryingRpcClient.java 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6409      +/-   ##
============================================
- Coverage     74.46%   74.19%   -0.27%     
- Complexity    22234    23306    +1072     
============================================
  Files          1963     2094     +131     
  Lines         82437    87069    +4632     
  Branches      10764    11446     +682     
============================================
+ Hits          61385    64602    +3217     
- Misses        15918    17013    +1095     
- Partials       5134     5454     +320     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

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

Thanks! 👍 👍

Copy link
Copy Markdown
Contributor

@minwoox minwoox left a comment

Choose a reason for hiding this comment

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

👍 👍 👍

@jrhee17 jrhee17 modified the milestones: 1.34.0, 1.35.0 Nov 24, 2025
@ikhoon
Copy link
Copy Markdown
Contributor

ikhoon commented Nov 24, 2025

Why is this PR rescheduled to 1.35.0? I was wondering if there is remaining issues in this PR that I missed.

@jrhee17
Copy link
Copy Markdown
Contributor Author

jrhee17 commented Nov 24, 2025

Why is this PR rescheduled to 1.35.0? I was wondering if there is remaining issues in this PR that I missed.

There are no remaining issues, I'm just more concerned of releasing on schedule at the moment.
I'll probably my PRs later once preparation for release is ready. (dependency updates, release notes, etc.)

@jrhee17 jrhee17 modified the milestones: 1.35.0, 1.34.0 Nov 26, 2025
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Nov 26, 2025

Walkthrough

A retry limiting feature is introduced supporting both concurrency-based and token-based retry throttling. The RetryLimiter interface provides two factory methods for creating limiters, with implementations enforcing retry constraints. RetryDecision now carries a permits value. Retry configuration propagates the limiter through execution paths, and retry clients check permissions before attempting retries, notifying limiters of decisions.

Changes

Cohort / File(s) Summary
RetryLimiter Core Abstractions
RetryLimiter.java, RetryLimiters.java
Introduces RetryLimiter interface with factory methods for concurrency-based and token-based limiting. Adds CatchingRetryLimiter wrapper for exception safety and AlwaysRetryLimiter default singleton.
RetryLimiter Implementations
ConcurrencyBasedRetryLimiter.java, TokenBasedRetryLimiter.java
Implements two RetryLimiter strategies: concurrency-based tracking active requests via AtomicLong, and token-based maintaining a token pool with permit deduction on decisions.
RetryConfig Integration
RetryConfig.java, RetryConfigBuilder.java
Adds retryLimiter field and propagates it through constructors. RetryConfigBuilder introduces retryLimiter() setter with AlwaysRetryLimiter as default.
RetryDecision Updates
RetryDecision.java
Introduces permits concept alongside backoff. Adds retry(Backoff, double permits) and noRetry(double permits) factory methods; DEFAULT now carries permits=1.
Retry Rule Builders
RetryRuleBuilder.java, RetryRuleWithContentBuilder.java
Makes build(RetryDecision) public, updates guard condition to check decision.backoff() != null instead of decision != noRetry().
Exception Handling
RetryLimitedException.java
New exception class with sampling-based singleton pattern. Includes optional writable stack trace when sampled via Flags.verboseExceptionSampler().
HTTP Retry Client
RetryingClient.java
Integrates retry limiter checks before non-initial attempts; enforces RetryLimitedException on denial. Threads RetryConfig through response handling paths to notify limiter of decisions.
RPC Retry Client
RetryingRpcClient.java
Introduces pre-check for retry permission via limiter.shouldRetry(); emits UnprocessedRequestException wrapping RetryLimitedException on denial. Forwards decisions to limiter.handleDecision().
Unit Tests
RetryLimiterTest.java
Comprehensive unit tests validating concurrency-based and token-based limiting, decision handling, and zero-permit behavior.
Integration Tests
RetryLimiterIntegrationTest.java
Tests concurrency limiting, token-based limiting, and exception handling across WebClient with RetryingClient decorator.
Thrift RPC Tests
RpcRetryLimiterTest.java
Validates retry limiter behavior with Thrift RPC clients using token-based limiting.

Sequence Diagram

sequenceDiagram
    participant Client
    participant RetryingClient
    participant RetryLimiter
    participant Upstream

    rect rgb(200, 220, 255)
    Note over Client,Upstream: Initial Request
    Client->>RetryingClient: execute(request)
    RetryingClient->>Upstream: send request
    Upstream-->>RetryingClient: response/error
    end

    rect rgb(255, 220, 200)
    Note over RetryingClient,RetryLimiter: Retry Attempt Decision
    alt Not First Attempt
        RetryingClient->>RetryLimiter: shouldRetry(ctx)
        alt Permission Denied
            RetryLimiter-->>RetryingClient: false
            RetryingClient-->>Client: RetryLimitedException
        else Permission Granted
            RetryLimiter-->>RetryingClient: true
            RetryingClient->>Upstream: send retry request
            Upstream-->>RetryingClient: response
        end
    end
    end

    rect rgb(220, 255, 220)
    Note over RetryingClient,RetryLimiter: Decision Notification
    alt Retry Decision Made
        RetryingClient->>RetryingClient: evaluate retry decision
        RetryingClient->>RetryLimiter: handleDecision(ctx, decision)
        Note over RetryLimiter: Update permits/tokens
    end
    end

    RetryingClient-->>Client: final response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • RetryDecision.java — Semantic changes to DEFAULT and retry factory methods; permits field integration affects equality and toString; carefully review state transitions
  • RetryingClient.java & RetryingRpcClient.java — Threading of RetryConfig and RetryLimiter decision handling across multiple response paths; verify permit deduction timing and exception propagation consistency
  • Token/Concurrency Limiters — Atomic operations and state management; verify thread safety of permit/token updates and bounds clamping
  • RetryConfig constructors — Multiple overloads now include RetryLimiter; verify wrapping with CatchingRetryLimiter applied consistently
  • Integration test coverage — Validate that exception cases and decision notification paths exercise the limiter correctly in end-to-end scenarios

Poem

🐰 Retries now throttled, no endless spree,
Tokens and concurrency set limits free,
Permits weighted, decisions flow,
Retry limiters steal the show! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.43% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Introduce RetryLimiter' clearly and concisely summarizes the main change—the introduction of a new retry limiting interface.
Description check ✅ Passed The description comprehensively explains the motivation (retry throttling aligned with gRPC and Envoy), the modifications made (RetryLimiter interface and RetryDecision#permits), and the resulting outcome.
Linked Issues check ✅ Passed The PR successfully implements the core requirements from issue #6282: introduces RetryLimiter interface with shouldRetry and handleDecision methods, adds RetryDecision#permits, and provides APIs to set RetryLimiter in RetryConfig.
Out of Scope Changes check ✅ Passed All changes are within scope: new RetryLimiter implementations (concurrency and token-based), RetryDecision enhancements, RetryConfig integration, and corresponding client updates to enforce retry limiting, plus test coverage.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@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: 2

🧹 Nitpick comments (2)
core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java (1)

202-205: Remove redundant mappedRetryConfig call.

mappedRetryConfig(ctx) is already called at line 174 and stored in config. The second call at line 202 is redundant and can be replaced with the existing config variable.

-        final RetryConfig<RpcResponse> retryConfig = mappedRetryConfig(ctx);
         final RetryRuleWithContent<RpcResponse> retryRule =
-                retryConfig.needsContentInRule() ?
-                retryConfig.retryRuleWithContent() : retryConfig.fromRetryRule();
+                config.needsContentInRule() ?
+                config.retryRuleWithContent() : config.fromRetryRule();
core/src/test/java/com/linecorp/armeria/client/retry/RetryLimiterIntegrationTest.java (1)

105-141: LGTM!

The test validates that exceptions thrown by a RetryLimiter are gracefully handled and converted to RetryLimitedException. The counter value of 1 confirms only the initial request executes.

Minor nit: The maxRequests variable on line 112 is unused and could be removed for clarity.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2bcdb85 and 1df8269.

📒 Files selected for processing (15)
  • core/src/main/java/com/linecorp/armeria/client/retry/ConcurrencyBasedRetryLimiter.java (1 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java (4 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigBuilder.java (4 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryDecision.java (3 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryLimitedException.java (1 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryLimiter.java (1 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryLimiters.java (1 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryRuleBuilder.java (1 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryRuleWithContentBuilder.java (1 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java (5 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java (3 hunks)
  • core/src/main/java/com/linecorp/armeria/client/retry/TokenBasedRetryLimiter.java (1 hunks)
  • core/src/test/java/com/linecorp/armeria/client/retry/RetryLimiterIntegrationTest.java (1 hunks)
  • core/src/test/java/com/linecorp/armeria/client/retry/RetryLimiterTest.java (1 hunks)
  • thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/RpcRetryLimiterTest.java (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.java

⚙️ CodeRabbit configuration file

**/*.java: - The primary coding conventions and style guide for this project are defined in site/src/pages/community/developer-guide.mdx. Please strictly adhere to this file as the ultimate source of truth for all style and convention-related feedback.

2. Specific check for @UnstableApi

  • Review all newly added public classes and methods to ensure they have the @UnstableApi annotation.
  • However, this annotation is NOT required under the following conditions:
    • If the class or method is located in a package containing .internal.
    • If a public method is part of a class that is already annotated with @UnstableApi.

Files:

  • core/src/main/java/com/linecorp/armeria/client/retry/RetryRuleWithContentBuilder.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryLimiters.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigBuilder.java
  • core/src/test/java/com/linecorp/armeria/client/retry/RetryLimiterTest.java
  • core/src/main/java/com/linecorp/armeria/client/retry/ConcurrencyBasedRetryLimiter.java
  • core/src/main/java/com/linecorp/armeria/client/retry/TokenBasedRetryLimiter.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryLimiter.java
  • core/src/test/java/com/linecorp/armeria/client/retry/RetryLimiterIntegrationTest.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryLimitedException.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java
  • thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/RpcRetryLimiterTest.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryRuleBuilder.java
  • core/src/main/java/com/linecorp/armeria/client/retry/RetryDecision.java
🧬 Code graph analysis (5)
core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigBuilder.java (1)
core/src/main/java/com/linecorp/armeria/client/retry/RetryLimiters.java (2)
  • RetryLimiters (24-65)
  • AlwaysRetryLimiter (56-64)
core/src/main/java/com/linecorp/armeria/client/retry/RetryLimitedException.java (1)
core/src/main/java/com/linecorp/armeria/common/Flags.java (1)
  • Flags (106-1897)
core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java (1)
core/src/main/java/com/linecorp/armeria/client/retry/RetryLimitedException.java (1)
  • RetryLimitedException (24-44)
thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/RpcRetryLimiterTest.java (5)
core/src/main/java/com/linecorp/armeria/client/UnprocessedRequestException.java (1)
  • UnprocessedRequestException (32-70)
core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java (1)
  • RetryConfig (35-251)
core/src/main/java/com/linecorp/armeria/client/retry/RetryDecision.java (1)
  • RetryDecision (30-117)
core/src/main/java/com/linecorp/armeria/client/retry/RetryLimitedException.java (1)
  • RetryLimitedException (24-44)
core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java (1)
  • RetryingRpcClient (41-254)
core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java (2)
core/src/main/java/com/linecorp/armeria/client/UnprocessedRequestException.java (1)
  • UnprocessedRequestException (32-70)
core/src/main/java/com/linecorp/armeria/client/retry/RetryLimitedException.java (1)
  • RetryLimitedException (24-44)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: build-macos-latest-jdk-21
  • GitHub Check: build-ubicloud-standard-16-jdk-17-leak
  • GitHub Check: build-windows-latest-jdk-21
  • GitHub Check: build-ubicloud-standard-16-jdk-11
  • GitHub Check: build-ubicloud-standard-16-jdk-17-min-java-11
  • GitHub Check: build-ubicloud-standard-16-jdk-8
  • GitHub Check: build-ubicloud-standard-16-jdk-17-min-java-17-coverage
  • GitHub Check: flaky-tests
  • GitHub Check: build-ubicloud-standard-16-jdk-21-snapshot-blockhound
  • GitHub Check: lint
  • GitHub Check: site
  • GitHub Check: Kubernetes Chaos test
  • GitHub Check: Summary
🔇 Additional comments (37)
core/src/main/java/com/linecorp/armeria/client/retry/RetryRuleWithContentBuilder.java (1)

66-77: LGTM!

The visibility change to public and the updated condition decision.backoff() != null are appropriate. The backoff-null check is more direct and aligns with the retry flow semantics.

core/src/main/java/com/linecorp/armeria/client/retry/RetryLimiters.java (2)

26-54: LGTM!

CatchingRetryLimiter provides appropriate defensive wrapping with fail-safe behavior (returning false on exception to prevent retries when limiter state is uncertain). Logging warnings helps with debugging.


56-64: LGTM!

AlwaysRetryLimiter provides a clean no-op default implementation for cases where retry limiting is not configured.

core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java (2)

174-182: LGTM!

The retry limiter check is correctly placed for non-initial attempts, and the exception wrapping with UnprocessedRequestException.of(RetryLimitedException.of()) follows the established pattern for unprocessed request errors.


210-212: LGTM!

The handleDecision call correctly notifies the limiter after a retry decision is made, enabling token/concurrency accounting.

core/src/main/java/com/linecorp/armeria/client/retry/RetryConfigBuilder.java (2)

130-139: LGTM!

The retryLimiter is correctly wired through both build paths (with RetryRule and RetryRuleWithContent).


116-123: Unable to verify @UnstableApi annotation coverage due to repository access issues.

The automated verification cannot be completed as the repository cannot be cloned. To determine if this public method requires the @UnstableApi annotation, manually verify whether RetryConfigBuilder has the annotation at the class level:

  • If @UnstableApi is present on the class declaration, the method is compliant.
  • If @UnstableApi is absent at class level, the method retryLimiter(RetryLimiter) at lines 116-123 must be annotated with @UnstableApi (per coding guidelines for public methods not in .internal packages).
core/src/main/java/com/linecorp/armeria/client/retry/TokenBasedRetryLimiter.java (3)

41-48: LGTM!

Constructor validation and initialization are correct. Starting tokenCount at maxTokens ensures the limiter begins with full capacity.


62-68: LGTM on the CAS loop implementation.

The compare-and-set loop with clamping to [0, maxTokens] correctly handles concurrent updates without locks.


55-69: Unable to verify the precision loss concern due to repository access issues and incomplete web documentation.

The repository clone is failing, preventing direct inspection of the RetryDecision interface definition and permits() method documentation. Web searches did not yield specific documentation about the permits() contract or whether fractional values are semantically meaningful in this context.

The concern itself is technically valid—casting double to int does truncate fractional values—but without access to:

  • The RetryDecision#permits() javadoc and contract
  • Design documentation explaining whether fractional permits are meaningful
  • Usage patterns and tests showing expected behavior

it cannot be determined whether this truncation is intentional or problematic. A developer with repository access should:

  1. Review the RetryDecision interface to understand the semantics of permits()
  2. Check if fractional permits are ever produced in practice
  3. Verify if truncation aligns with the retry token model's design
thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/RpcRetryLimiterTest.java (1)

44-49: Verify token consumption aligns with test expectations.

The RetryDecision.retry(fixed) doesn't specify a permits value, which likely defaults to 0. With a token-based limiter configured as tokenBased(3, 1), the tokens might not be consumed as expected since the default permits may not trigger token deduction. Consider explicitly specifying permits in the RetryDecision to ensure the test accurately validates token-based limiting behavior:

 final RetryRule retryRule = RetryRule.builder()
                                      .onException()
-                                     .build(RetryDecision.retry(fixed));
+                                     .build(RetryDecision.retry(fixed, 1));
core/src/main/java/com/linecorp/armeria/client/retry/RetryRuleBuilder.java (1)

66-81: LGTM!

The visibility change to public enables external usage via RetryDecision for the new RetryLimiter feature. The guard condition refinement to check decision.backoff() != null is semantically correct since noRetry decisions have a null backoff.

core/src/test/java/com/linecorp/armeria/client/retry/RetryLimiterIntegrationTest.java (2)

36-71: LGTM!

The test correctly validates concurrency-based retry limiting. The logic ensures 3 concurrent retries hold slots (via streaming responses), and the 4th request's retry is blocked. The counter assertion of maxRequests * 2 + 1 = 7 correctly accounts for all initial attempts plus retries.


73-103: LGTM!

The test accurately emulates gRPC-style retry throttling with token-based limiting. The counter value of 2 correctly reflects the token consumption pattern: initial request plus one retry before tokens fall to the threshold.

core/src/main/java/com/linecorp/armeria/client/retry/RetryLimiter.java (1)

42-80: LGTM!

The interface is well-designed with clear Javadoc, appropriate @UnstableApi annotation, and sensible defaults. The factory methods provide convenient access to the built-in limiter implementations.

core/src/main/java/com/linecorp/armeria/client/retry/ConcurrencyBasedRetryLimiter.java (1)

37-46: LGTM!

The concurrency control logic is thread-safe. The atomic increment-check-decrement pattern correctly handles race conditions, and the completion callback ensures proper cleanup when retries complete.

core/src/test/java/com/linecorp/armeria/client/retry/RetryLimiterTest.java (8)

30-37: LGTM!

Test correctly verifies that multiple retry attempts are allowed when under the concurrency limit.


39-51: LGTM!

Good test for verifying concurrency limiting blocks excess retries and correctly handles cancellation to free up permits.


53-66: LGTM!

Correctly verifies that concurrency-based limiter ignores handleDecision calls since it tracks active retries rather than token consumption.


68-74: LGTM!

Simple verification that token-based limiter allows retries when tokens are available.


76-88: LGTM!

Test correctly verifies that token-based limiter blocks retries when tokens are exhausted after consuming permits via handleDecision.


90-107: LGTM!

Correctly verifies that negative permit values in handleDecision replenish tokens, allowing subsequent retries.


109-119: LGTM!

Correctly verifies that zero-permit decisions don't consume tokens, allowing unlimited retries in that scenario.


121-123: LGTM!

Simple helper method for creating test contexts.

core/src/main/java/com/linecorp/armeria/client/retry/RetryDecision.java (5)

63-68: Consider adding @UnstableApi annotation to the new public method.

Same as the retry(Backoff, double) method - this new public API should have @UnstableApi if the class doesn't already have it.


92-100: Consider adding @UnstableApi annotation to the new public method.

The permits() accessor is a new public API that exposes the permits value to users. Consider adding @UnstableApi annotation.


102-116: LGTM!

Good use of MoreObjects.ToStringHelper for structured toString output. The type detection logic correctly handles NEXT, RETRY, and NO_RETRY cases.


32-42: LGTM!

Good default behavior - existing retry(Backoff) calls now transparently use permits=1, maintaining backward compatibility while supporting the new limiter functionality.


44-53: I apologize, but I'm unable to access the repository to verify whether the RetryDecision class already has the @UnstableApi annotation. The repository clone is failing consistently.

However, based on the information provided:

Verify whether the RetryDecision class already has @UnstableApi annotation at the class level.

Per the coding guidelines, the new public method retry(Backoff backoff, double permits) requires @UnstableApi only if:

  1. The class does NOT already have @UnstableApi, AND
  2. The package does NOT contain .internal

Since the package is com.linecorp.armeria.client.retry (which does not contain .internal), the annotation is needed on the method unless the class itself is already annotated.

core/src/main/java/com/linecorp/armeria/client/retry/RetryConfig.java (3)

115-115: Defensive wrapping with CatchingRetryLimiter is a good pattern.

Wrapping user-provided limiters ensures that exceptions in custom limiter implementations don't break the retry flow. This is a good defensive coding practice.


152-156: LGTM!

Correctly preserves the retryLimiter when converting to builder, ensuring round-trip configuration works as expected.


205-210: I'm encountering technical issues accessing the repository directly. However, I can work with the information provided in the coding guidelines and the review context.

Based on the coding guidelines provided:

  • New public methods require @UnstableApi annotation unless the containing class already has it
  • The package com.linecorp.armeria.client.retry does not contain .internal, so that exception doesn't apply

The review comment correctly identifies that the retryLimiter() public accessor needs @UnstableApi annotation if the RetryConfig class itself doesn't already have it.

Since I cannot directly verify the current state of the RetryConfig class annotation due to system constraints, I need to flag this for manual verification:

Add @UnstableApi annotation to the retryLimiter() public method unless the RetryConfig class is already annotated with @UnstableApi.

Per the coding guidelines, new public API additions must have the @UnstableApi annotation unless the containing class already provides it. Verify the class-level annotations on RetryConfig to determine if this method-level annotation is necessary.

core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java (5)

315-322: LGTM - Good placement of retry limiter check.

The limiter check is correctly placed:

  1. After creating the derived context (so limiter has access to request info)
  2. Before executing the actual request (to prevent unnecessary work)
  3. Only for non-initial attempts (first attempt should always proceed)

The RetryLimitedException failure handling is appropriate.


533-540: LGTM!

The handleDecision call is correctly guarded by null check and placed before checking the backoff, ensuring the limiter is notified of all decisions (both retry and no-retry).


363-378: LGTM!

Config is correctly threaded through handleResponseWithoutContent to handleRetryDecision.


419-426: LGTM!

Config is correctly propagated through the streaming response handling path.


458-464: LGTM!

Config is correctly propagated through the aggregated response handling path with content-based retry rules.

/**
* An exception thrown when a retry is limited by a {@link RetryLimiter}.
*/
public final class RetryLimitedException extends RuntimeException {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Missing @UnstableApi annotation on new public class.

Per coding guidelines, newly added public classes should have the @UnstableApi annotation.

+import com.linecorp.armeria.common.annotation.UnstableApi;
+
 /**
  * An exception thrown when a retry is limited by a {@link RetryLimiter}.
  */
+@UnstableApi
 public final class RetryLimitedException extends RuntimeException {
🤖 Prompt for AI Agents
In
core/src/main/java/com/linecorp/armeria/client/retry/RetryLimitedException.java
around line 24, the new public class lacks the required @UnstableApi annotation;
add the annotation above the class declaration and import the annotation
(com.linecorp.armeria.common.annotation.UnstableApi) so the class is marked
unstable per coding guidelines.

Comment on lines +37 to +39
private RetryLimitedException(boolean enableSuppression) {
super(null, null, enableSuppression, isSampled());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential bug: INSTANCE may retain stack trace unintentionally.

When INSTANCE is created at static initialization, isSampled() is called for writableStackTrace. If the sampler happens to return true at that moment, INSTANCE will have a stack trace captured, defeating the purpose of the lightweight singleton.

The writableStackTrace parameter should use enableSuppression directly to ensure INSTANCE never captures a stack trace.

 private RetryLimitedException(boolean enableSuppression) {
-    super(null, null, enableSuppression, isSampled());
+    super(null, null, enableSuppression, enableSuppression);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private RetryLimitedException(boolean enableSuppression) {
super(null, null, enableSuppression, isSampled());
}
private RetryLimitedException(boolean enableSuppression) {
super(null, null, enableSuppression, enableSuppression);
}
🤖 Prompt for AI Agents
In
core/src/main/java/com/linecorp/armeria/client/retry/RetryLimitedException.java
around lines 37 to 39, the private constructor currently passes isSampled() as
the writableStackTrace argument which can cause the static INSTANCE to capture a
stack trace; change the constructor call to pass enableSuppression for the
writableStackTrace parameter instead of isSampled(), so the INSTANCE never
creates a stack trace while preserving suppression behavior.

@jrhee17 jrhee17 merged commit 8f16b72 into line:main Nov 26, 2025
16 of 17 checks passed
jrhee17 added a commit to jrhee17/armeria that referenced this pull request Nov 26, 2025
Motivation:

This changeset attempts to solve the same problem as line#6318.

Retry limiting is a concept which limits the number of retries in case a
system undergoes a prolonged period of service degradation.
gRPC offers a
[token-based](https://github.com/grpc/proposal/blob/master/A6-client-retries.md#throttling-retry-attempts-and-hedged-rpcs)
configuration which limits retries depending on certain predicates,
whereas envoy offers a simple [concurrency limiting
configuration](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/circuit_breaker.proto#envoy-v3-api-msg-config-cluster-v3-circuitbreakers-thresholds-retrybudget)
based on the number of active retries.

To support token-based retry limiters, I propose that
`RetryDecision#permits` is added as metadata which could signal to
`RetryLimiter` whether retries should be further made. This could be
useful for systems behind load balancers, as the load balancer may
return certain status codes depending on the health upstream.

To support simple concurrency-based retry limiters, I propose that a
`RetryLimiter#shouldRetry` method is called right before a retry is
executed.

Modifications:

- Introduced `RetryLimiter` which acts as an extension to dynamically
limit retries.
- `RetryLimiter#shouldRetry` decides whether a retry should be executed
- `RetryLimiter#handleDecision` is invoked when a `RetryDecision` is
made. `RetryDecision#permits` may be used to update the internal state
and decide whether retries should be allowed.
- Added `RetryDecision#permits`
- Added APIs so users can set `RetryLimiter` to `RetryConfig`

Result:

- Users can specify `RetryLimiter` to limit retry requests.
- Closes line#6282

<!--
Visit this URL to learn more about how to write a pull request
description:

https://armeria.dev/community/developer-guide#how-to-write-pull-request-description
-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable retry throttling

3 participants