diff --git a/CHANGELOG.md b/CHANGELOG.md
index d185e1d..ee991f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,8 +9,27 @@ may break public API until 1.0.0 ships.
## [Unreleased]
+### Changed
+
+- **Breaking** — `RetryPolicy`'s canonical constructor gains a fourth `Duration`, `maxTotalDelay`,
+ after `maxDelay` (seven components, was six). Migration: prefer the new `RetryPolicy.builder()`
+ (starts from `defaults()`, one setter per knob), or insert `Duration.ofMinutes(1)` — the default —
+ as the fourth positional argument. Deconstruction patterns over the record gain a component too.
+ Pre-1.0 per [ADR-019](docs/adr/019-pre-10-stability-policy.md); see
+ [ADR-027](docs/adr/027-retry-policy-builder-and-budget.md).
+
### Added
+- **`fanar-core`** — a total sleep budget for retries ([ADR-027](docs/adr/027-retry-policy-builder-and-budget.md)):
+ `RetryPolicy.maxTotalDelay()` (default 1 min — the worst case the other defaults already allowed, so
+ nothing changes at the defaults) bounds the sum of all sleeps within one call; a sleep that would exceed
+ it — computed back-off or honoured `Retry-After` hint — is never started, retrying ends and the exception
+ surfaces with the hint preserved, like the ADR-025 ceiling. Plus `RetryPolicy.builder()` and
+ `withMaxTotalDelay(...)`.
+- **`fanar-spring-boot-4-starter`** — `fanar.retry.max-total-delay` (default `1m`); the `RetryPolicy` bean
+ is built through the builder, so a `max-delay` raised above the budget fails the context at startup
+ ([ADR-020](docs/adr/020-spring-boot-4-starter.md), amended).
+
- **`fanar-core`** — rate-limit visibility ([ADR-026](docs/adr/026-rate-limit-telemetry.md)): the retry boundary
publishes `fanar.ratelimit.limit` / `.remaining` / `.reset` / `.policy` observation attributes from every response
that carries Fanar's rate-limit headers (successes and 429s alike; the last attempt's values win), and both HTTP
diff --git a/core/src/main/java/qa/fanar/core/RetryPolicy.java b/core/src/main/java/qa/fanar/core/RetryPolicy.java
index 939ac5e..203c6d1 100644
--- a/core/src/main/java/qa/fanar/core/RetryPolicy.java
+++ b/core/src/main/java/qa/fanar/core/RetryPolicy.java
@@ -8,19 +8,25 @@
* Retry configuration for the SDK's built-in retry interceptor.
*
*
Immutable and thread-safe. Construct via {@link #defaults()} or {@link #disabled()} for the
- * canonical presets, or via the record constructor for full control. Derive variants through the
- * {@code with*} methods — each returns a new record.
+ * canonical presets, via {@link #builder()} (which starts from the defaults) for full control, or
+ * via the record constructor. Derive variants through the {@code with*} methods — each returns a
+ * new record. The builder is the stable way to set knobs: the canonical constructor changes
+ * arity whenever a knob is added (ADR-027).
*
* This type holds only the configuration. The retry loop — applying backoff, honoring
* {@code Retry-After}, computing jitter — lives in the SDK's internal retry interceptor.
*
* Default policy
* {@link #defaults()} returns 3 attempts, exponential backoff with
- * {@link JitterStrategy#FULL full jitter}, base 500 ms, cap 30 s, multiplier 2.0, and
- * the {@link #isDefaultRetryable default retryable predicate} (transient server-side errors and
- * transport failures — never client-side or content-filter errors). The cap also bounds
- * honoured server {@code Retry-After} hints: a hint above it ends retrying and the exception
- * surfaces immediately with the hint preserved (ADR-025).
+ * {@link JitterStrategy#FULL full jitter}, base 500 ms, cap 30 s, a total sleep budget of
+ * 1 min, multiplier 2.0, and the {@link #isDefaultRetryable default retryable predicate}
+ * (transient server-side errors and transport failures — never client-side or content-filter
+ * errors). The cap also bounds honoured server {@code Retry-After} hints: a hint above it ends
+ * retrying and the exception surfaces immediately with the hint preserved (ADR-025). The budget
+ * bounds the sum of all sleeps within one call: a sleep that would push the total over it
+ * is never taken — retrying ends and the exception surfaces, hint preserved (ADR-027). At the
+ * defaults the budget equals the worst case the other knobs allow (two sleeps of at most 30 s),
+ * so it only bites once {@code maxAttempts} or {@code maxDelay} are raised.
*
* Validation
* The canonical constructor validates all invariants at construction time. {@code with*}
@@ -34,12 +40,18 @@
* {@code Retry-After} hints — a hint above it ends retrying and the
* exception surfaces with the hint preserved (ADR-025); must be
* positive, ≥ {@code baseDelay}, and representable in milliseconds
+ * @param maxTotalDelay budget for the sum of all sleeps within one call — the next sleep
+ * (computed back-off or honoured hint) is taken only if the total stays
+ * within it, otherwise retrying ends and the exception surfaces with
+ * the hint preserved (ADR-027); must be positive, ≥ {@code maxDelay},
+ * and representable in milliseconds
* @param backoffMultiplier factor applied to the backoff on each retry; must be ≥ 1.0
* @param jitter jitter policy applied to the computed backoff
* @param retryable predicate deciding whether a given exception is worth retrying.
* Consulted only while the policy can still honour a retry: the
- * attempt budget ({@code maxAttempts}) and the delay ceiling
- * ({@code maxDelay}) end retrying regardless of its answer
+ * attempt budget ({@code maxAttempts}), the delay ceiling
+ * ({@code maxDelay}) and the total sleep budget ({@code maxTotalDelay})
+ * end retrying regardless of its answer
*
* @author Oussama Mahjoub
*/
@@ -47,6 +59,7 @@ public record RetryPolicy(
int maxAttempts,
Duration baseDelay,
Duration maxDelay,
+ Duration maxTotalDelay,
double backoffMultiplier,
JitterStrategy jitter,
Predicate retryable
@@ -78,6 +91,18 @@ public record RetryPolicy(
throw new IllegalArgumentException(
"maxDelay must be representable in milliseconds, got " + maxDelay);
}
+ Objects.requireNonNull(maxTotalDelay, "maxTotalDelay");
+ if (maxTotalDelay.isNegative() || maxTotalDelay.isZero()) {
+ throw new IllegalArgumentException("maxTotalDelay must be positive, got " + maxTotalDelay);
+ }
+ if (maxTotalDelay.compareTo(maxDelay) < 0) {
+ throw new IllegalArgumentException(
+ "maxTotalDelay (" + maxTotalDelay + ") must be >= maxDelay (" + maxDelay + ")");
+ }
+ if (maxTotalDelay.compareTo(MAX_REPRESENTABLE_DELAY) > 0) {
+ throw new IllegalArgumentException(
+ "maxTotalDelay must be representable in milliseconds, got " + maxTotalDelay);
+ }
if (backoffMultiplier < 1.0) {
throw new IllegalArgumentException(
"backoffMultiplier must be >= 1.0, got " + backoffMultiplier);
@@ -88,7 +113,7 @@ public record RetryPolicy(
/**
* The SDK's default retry policy: 3 attempts, exponential backoff with full jitter, base 500 ms,
- * cap 30 s, multiplier 2.0, and the default retryable predicate.
+ * cap 30 s, total sleep budget 1 min, multiplier 2.0, and the default retryable predicate.
*
* @return a new policy instance with the documented defaults
*/
@@ -97,6 +122,7 @@ public static RetryPolicy defaults() {
3,
Duration.ofMillis(500),
Duration.ofSeconds(30),
+ Duration.ofMinutes(1),
2.0,
JitterStrategy.FULL,
RetryPolicy::isDefaultRetryable);
@@ -113,6 +139,7 @@ public static RetryPolicy disabled() {
1,
Duration.ofMillis(500),
Duration.ofSeconds(30),
+ Duration.ofMinutes(1),
2.0,
JitterStrategy.FULL,
RetryPolicy::isDefaultRetryable);
@@ -142,27 +169,35 @@ public static boolean isDefaultRetryable(FanarException e) {
/** @return a new policy with the given {@code maxAttempts}, all other fields unchanged */
public RetryPolicy withMaxAttempts(int maxAttempts) {
- return new RetryPolicy(maxAttempts, baseDelay, maxDelay, backoffMultiplier, jitter, retryable);
+ return new RetryPolicy(maxAttempts, baseDelay, maxDelay, maxTotalDelay, backoffMultiplier, jitter, retryable);
}
/** @return a new policy with the given {@code baseDelay}, all other fields unchanged */
public RetryPolicy withBaseDelay(Duration baseDelay) {
- return new RetryPolicy(maxAttempts, baseDelay, maxDelay, backoffMultiplier, jitter, retryable);
+ return new RetryPolicy(maxAttempts, baseDelay, maxDelay, maxTotalDelay, backoffMultiplier, jitter, retryable);
}
/** @return a new policy with the given {@code maxDelay}, all other fields unchanged */
public RetryPolicy withMaxDelay(Duration maxDelay) {
- return new RetryPolicy(maxAttempts, baseDelay, maxDelay, backoffMultiplier, jitter, retryable);
+ return new RetryPolicy(maxAttempts, baseDelay, maxDelay, maxTotalDelay, backoffMultiplier, jitter, retryable);
+ }
+
+ /**
+ * @return a new policy with the given {@code maxTotalDelay}, all other fields unchanged
+ * @since 0.4.0
+ */
+ public RetryPolicy withMaxTotalDelay(Duration maxTotalDelay) {
+ return new RetryPolicy(maxAttempts, baseDelay, maxDelay, maxTotalDelay, backoffMultiplier, jitter, retryable);
}
/** @return a new policy with the given {@code backoffMultiplier}, all other fields unchanged */
public RetryPolicy withBackoffMultiplier(double backoffMultiplier) {
- return new RetryPolicy(maxAttempts, baseDelay, maxDelay, backoffMultiplier, jitter, retryable);
+ return new RetryPolicy(maxAttempts, baseDelay, maxDelay, maxTotalDelay, backoffMultiplier, jitter, retryable);
}
/** @return a new policy with the given {@code jitter}, all other fields unchanged */
public RetryPolicy withJitter(JitterStrategy jitter) {
- return new RetryPolicy(maxAttempts, baseDelay, maxDelay, backoffMultiplier, jitter, retryable);
+ return new RetryPolicy(maxAttempts, baseDelay, maxDelay, maxTotalDelay, backoffMultiplier, jitter, retryable);
}
/**
@@ -170,6 +205,97 @@ public RetryPolicy withJitter(JitterStrategy jitter) {
* the attempt budget and the delay ceiling still apply regardless of its answer
*/
public RetryPolicy withRetryable(Predicate retryable) {
- return new RetryPolicy(maxAttempts, baseDelay, maxDelay, backoffMultiplier, jitter, retryable);
+ return new RetryPolicy(maxAttempts, baseDelay, maxDelay, maxTotalDelay, backoffMultiplier, jitter, retryable);
+ }
+
+ /**
+ * Begin building a policy from {@link #defaults()}: every knob starts at its default and only
+ * the ones set change. Validation happens in {@link Builder#build()}, exactly as for the
+ * canonical constructor.
+ *
+ * @return a new builder
+ * @since 0.4.0
+ */
+ public static Builder builder() {
+ return new Builder(defaults());
+ }
+
+ /**
+ * Fluent builder for {@link RetryPolicy} (ADR-027). Not thread-safe; build once.
+ *
+ * @since 0.4.0
+ */
+ public static final class Builder {
+
+ private int maxAttempts;
+ private Duration baseDelay;
+ private Duration maxDelay;
+ private Duration maxTotalDelay;
+ private double backoffMultiplier;
+ private JitterStrategy jitter;
+ private Predicate retryable;
+
+ private Builder(RetryPolicy start) {
+ this.maxAttempts = start.maxAttempts();
+ this.baseDelay = start.baseDelay();
+ this.maxDelay = start.maxDelay();
+ this.maxTotalDelay = start.maxTotalDelay();
+ this.backoffMultiplier = start.backoffMultiplier();
+ this.jitter = start.jitter();
+ this.retryable = start.retryable();
+ }
+
+ /** @param maxAttempts total attempts including the first; {@code 1} disables retries */
+ public Builder maxAttempts(int maxAttempts) {
+ this.maxAttempts = maxAttempts;
+ return this;
+ }
+
+ /** @param baseDelay initial backoff delay */
+ public Builder baseDelay(Duration baseDelay) {
+ this.baseDelay = baseDelay;
+ return this;
+ }
+
+ /** @param maxDelay cap on one sleep and ceiling on honoured {@code Retry-After} hints (ADR-025) */
+ public Builder maxDelay(Duration maxDelay) {
+ this.maxDelay = maxDelay;
+ return this;
+ }
+
+ /** @param maxTotalDelay budget for the sum of all sleeps within one call (ADR-027) */
+ public Builder maxTotalDelay(Duration maxTotalDelay) {
+ this.maxTotalDelay = maxTotalDelay;
+ return this;
+ }
+
+ /** @param backoffMultiplier factor applied to the backoff on each retry */
+ public Builder backoffMultiplier(double backoffMultiplier) {
+ this.backoffMultiplier = backoffMultiplier;
+ return this;
+ }
+
+ /** @param jitter jitter policy applied to the computed backoff */
+ public Builder jitter(JitterStrategy jitter) {
+ this.jitter = jitter;
+ return this;
+ }
+
+ /** @param retryable predicate deciding whether an exception is worth retrying */
+ public Builder retryable(Predicate retryable) {
+ this.retryable = retryable;
+ return this;
+ }
+
+ /**
+ * Validate and build.
+ *
+ * @return the policy
+ * @throws IllegalArgumentException or {@link NullPointerException} on an invalid knob,
+ * exactly as the canonical constructor
+ */
+ public RetryPolicy build() {
+ return new RetryPolicy(maxAttempts, baseDelay, maxDelay, maxTotalDelay, backoffMultiplier, jitter, retryable);
+ }
}
}
diff --git a/core/src/main/java/qa/fanar/core/internal/retry/RetryInterceptor.java b/core/src/main/java/qa/fanar/core/internal/retry/RetryInterceptor.java
index a38f94d..10588f4 100644
--- a/core/src/main/java/qa/fanar/core/internal/retry/RetryInterceptor.java
+++ b/core/src/main/java/qa/fanar/core/internal/retry/RetryInterceptor.java
@@ -43,6 +43,10 @@
* sleep uses the hint instead of the computed back-off curve. A hint above {@code maxDelay}
* ends retrying immediately — no sleep, no burned attempt — and the exception surfaces with
* the hint preserved (ADR-025).
+ * Never sleeps past {@link RetryPolicy#maxTotalDelay()} in total for one call: when the next
+ * sleep — computed back-off or honoured hint — would push the cumulative sleep over the
+ * budget, retrying ends without sleeping and the exception surfaces with the hint preserved
+ * (ADR-027).
* Otherwise sleeps for {@code baseDelay * multiplier^(attempt-1)} capped at {@code maxDelay},
* with {@link JitterStrategy} applied (none / full / equal).
* Records {@link FanarObservationAttributes#HTTP_STATUS_CODE} for every response received
@@ -80,6 +84,7 @@ public RetryInterceptor(RetryPolicy policy) {
@Override
public HttpResponse intercept(HttpRequest request, Chain chain) {
int attempt = 0;
+ Duration slept = Duration.ZERO;
while (true) {
attempt++;
try {
@@ -97,11 +102,15 @@ public HttpResponse intercept(HttpRequest request, Chain chain) {
if (attempt >= policy.maxAttempts()
|| !policy.retryable().test(e)
|| (hint != null && hint.compareTo(policy.maxDelay()) > 0)) {
- recordRetryCount(chain, attempt - 1);
- throw e;
+ throw surfaced(chain, attempt, e);
+ }
+ Duration delay = hint != null ? hint : backoff(attempt);
+ if (slept.plus(delay).compareTo(policy.maxTotalDelay()) > 0) {
+ throw surfaced(chain, attempt, e);
}
chain.observation().event("retry_attempt");
- sleepOrAbort(hint != null ? hint : backoff(attempt));
+ sleepOrAbort(delay);
+ slept = slept.plus(delay);
}
}
}
@@ -164,6 +173,12 @@ private static void recordRateLimit(Chain chain, HttpResponse> response) {
}
}
+ /** Retrying ends here: record the exit and hand the exception back to be thrown. */
+ private static FanarException surfaced(Chain chain, int attempt, FanarException e) {
+ recordRetryCount(chain, attempt - 1);
+ return e;
+ }
+
private static void recordRetryCount(Chain chain, int retries) {
chain.observation().attribute(FanarObservationAttributes.FANAR_RETRY_COUNT, retries);
}
diff --git a/core/src/test/java/qa/fanar/core/FanarClientRetryIntegrationTest.java b/core/src/test/java/qa/fanar/core/FanarClientRetryIntegrationTest.java
index 1320ecf..bf393b3 100644
--- a/core/src/test/java/qa/fanar/core/FanarClientRetryIntegrationTest.java
+++ b/core/src/test/java/qa/fanar/core/FanarClientRetryIntegrationTest.java
@@ -209,6 +209,38 @@ void disabledPolicyStillMapsErrorsButNeverRetries() {
assertEquals(1, server.hits());
}
+ // --- total sleep budget (ADR-027) ---------------------------------------------------------
+
+ @Test
+ void retryAfterHintsBeyondTheTotalBudgetEndRetrying() {
+ // Two 429s each asking for 1 s: each within the 1 s ceiling, so ADR-025 alone would sleep
+ // both. A 1 s total budget admits the first hint (exactly the budget) and refuses the second
+ // — the exception surfaces after one retry, hint preserved, no further request is made.
+ server.enqueue(
+ Reply.of(429, "slow down", Map.of("Retry-After", "1")),
+ Reply.of(429, "slow down again", Map.of("Retry-After", "1")));
+ RecordingObservability obs = new RecordingObservability();
+ RetryPolicy budgeted = RetryPolicy.builder()
+ .maxDelay(Duration.ofSeconds(1))
+ .maxTotalDelay(Duration.ofSeconds(1))
+ .build();
+
+ long started = System.nanoTime();
+ FanarRateLimitException ex;
+ try (FanarClient client = client(budgeted, obs)) {
+ ex = assertThrows(FanarRateLimitException.class, () -> client.chat().send(ping()));
+ }
+ long elapsedMs = (System.nanoTime() - started) / 1_000_000L;
+
+ assertEquals(2, server.hits(), "one retry, then the budget ends it");
+ assertEquals(Duration.ofSeconds(1), ex.retryAfter(), "the refused hint is preserved");
+ assertTrue(elapsedMs >= 950, "the first hint must be slept, elapsed " + elapsedMs + " ms");
+ assertTrue(elapsedMs < 5_000, "the second must not, elapsed " + elapsedMs + " ms");
+ assertEquals(List.of("retry_attempt"), obs.events);
+ assertEquals(1, obs.attributes.get(FanarObservationAttributes.FANAR_RETRY_COUNT));
+ assertSame(ex, obs.errors.getFirst());
+ }
+
// --- rate-limit visibility (ADR-026) ------------------------------------------------------
@Test
diff --git a/core/src/test/java/qa/fanar/core/RetryPolicyTest.java b/core/src/test/java/qa/fanar/core/RetryPolicyTest.java
index 0f17f8d..83dd1cf 100644
--- a/core/src/test/java/qa/fanar/core/RetryPolicyTest.java
+++ b/core/src/test/java/qa/fanar/core/RetryPolicyTest.java
@@ -1,6 +1,7 @@
package qa.fanar.core;
import java.time.Duration;
+import java.util.function.Predicate;
import java.time.temporal.ChronoUnit;
import java.util.stream.Stream;
@@ -26,6 +27,7 @@ void defaultsMatchAdr014() {
assertEquals(3, p.maxAttempts());
assertEquals(Duration.ofMillis(500), p.baseDelay());
assertEquals(Duration.ofSeconds(30), p.maxDelay());
+ assertEquals(Duration.ofMinutes(1), p.maxTotalDelay(), "the worst case the other defaults allow (ADR-027)");
assertEquals(2.0, p.backoffMultiplier());
assertEquals(JitterStrategy.FULL, p.jitter());
}
@@ -54,56 +56,56 @@ void disabledIsStillValid() {
@Test
void rejectsMaxAttemptsBelowOne() {
assertThrows(IllegalArgumentException.class, () ->
- new RetryPolicy(0, Duration.ofMillis(500), Duration.ofSeconds(30), 2.0,
+ new RetryPolicy(0, Duration.ofMillis(500), Duration.ofSeconds(30), Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, e -> true));
}
@Test
void rejectsNullBaseDelay() {
assertThrows(NullPointerException.class, () ->
- new RetryPolicy(3, null, Duration.ofSeconds(30), 2.0,
+ new RetryPolicy(3, null, Duration.ofSeconds(30), Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, e -> true));
}
@Test
void rejectsNegativeBaseDelay() {
assertThrows(IllegalArgumentException.class, () ->
- new RetryPolicy(3, Duration.ofMillis(-500), Duration.ofSeconds(30), 2.0,
+ new RetryPolicy(3, Duration.ofMillis(-500), Duration.ofSeconds(30), Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, e -> true));
}
@Test
void rejectsZeroBaseDelay() {
assertThrows(IllegalArgumentException.class, () ->
- new RetryPolicy(3, Duration.ZERO, Duration.ofSeconds(30), 2.0,
+ new RetryPolicy(3, Duration.ZERO, Duration.ofSeconds(30), Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, e -> true));
}
@Test
void rejectsNullMaxDelay() {
assertThrows(NullPointerException.class, () ->
- new RetryPolicy(3, Duration.ofMillis(500), null, 2.0,
+ new RetryPolicy(3, Duration.ofMillis(500), null, Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, e -> true));
}
@Test
void rejectsZeroMaxDelay() {
assertThrows(IllegalArgumentException.class, () ->
- new RetryPolicy(3, Duration.ofMillis(500), Duration.ZERO, 2.0,
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ZERO, Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, e -> true));
}
@Test
void rejectsNegativeMaxDelay() {
assertThrows(IllegalArgumentException.class, () ->
- new RetryPolicy(3, Duration.ofMillis(500), Duration.ofMillis(-30), 2.0,
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofMillis(-30), Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, e -> true));
}
@Test
void rejectsMaxDelayLessThanBaseDelay() {
assertThrows(IllegalArgumentException.class, () ->
- new RetryPolicy(3, Duration.ofSeconds(30), Duration.ofMillis(500), 2.0,
+ new RetryPolicy(3, Duration.ofSeconds(30), Duration.ofMillis(500), Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, e -> true));
}
@@ -115,28 +117,67 @@ void rejectsMaxDelayNotRepresentableInMilliseconds() {
RetryPolicy.defaults().withMaxDelay(ChronoUnit.FOREVER.getDuration()));
assertThrows(IllegalArgumentException.class, () ->
RetryPolicy.defaults().withMaxDelay(Duration.ofSeconds(Long.MAX_VALUE)));
+ // The budget must keep up with the ceiling (ADR-027): raise it first.
assertEquals(Duration.ofMillis(Long.MAX_VALUE - 1),
- RetryPolicy.defaults().withMaxDelay(Duration.ofMillis(Long.MAX_VALUE - 1)).maxDelay());
+ RetryPolicy.defaults()
+ .withMaxTotalDelay(Duration.ofMillis(Long.MAX_VALUE - 1))
+ .withMaxDelay(Duration.ofMillis(Long.MAX_VALUE - 1))
+ .maxDelay());
+ }
+
+ @Test
+ void rejectsNullMaxTotalDelay() {
+ assertThrows(NullPointerException.class, () ->
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), null, 2.0,
+ JitterStrategy.FULL, e -> true));
+ }
+
+ @Test
+ void rejectsZeroMaxTotalDelay() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), Duration.ZERO, 2.0,
+ JitterStrategy.FULL, e -> true));
+ }
+
+ @Test
+ void rejectsNegativeMaxTotalDelay() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), Duration.ofSeconds(-1), 2.0,
+ JitterStrategy.FULL, e -> true));
+ }
+
+ @Test
+ void rejectsMaxTotalDelayBelowMaxDelay() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), Duration.ofSeconds(29), 2.0,
+ JitterStrategy.FULL, e -> true));
+ }
+
+ @Test
+ void rejectsMaxTotalDelayNotRepresentableInMilliseconds() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), Duration.ofMillis(Long.MAX_VALUE), 2.0,
+ JitterStrategy.FULL, e -> true));
}
@Test
void rejectsBackoffMultiplierBelowOne() {
assertThrows(IllegalArgumentException.class, () ->
- new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), 0.5,
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), Duration.ofMinutes(1), 0.5,
JitterStrategy.FULL, e -> true));
}
@Test
void rejectsNullJitter() {
assertThrows(NullPointerException.class, () ->
- new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), 2.0,
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), Duration.ofMinutes(1), 2.0,
null, e -> true));
}
@Test
void rejectsNullRetryable() {
assertThrows(NullPointerException.class, () ->
- new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), 2.0,
+ new RetryPolicy(3, Duration.ofMillis(500), Duration.ofSeconds(30), Duration.ofMinutes(1), 2.0,
JitterStrategy.FULL, null));
}
@@ -149,6 +190,7 @@ void withMaxAttemptsReplacesOnlyThatField() {
assertEquals(5, q.maxAttempts());
assertEquals(p.baseDelay(), q.baseDelay());
assertEquals(p.maxDelay(), q.maxDelay());
+ assertEquals(p.maxTotalDelay(), q.maxTotalDelay());
assertEquals(p.backoffMultiplier(), q.backoffMultiplier());
assertEquals(p.jitter(), q.jitter());
assertSame(p.retryable(), q.retryable());
@@ -171,6 +213,17 @@ void withMaxDelayReplacesOnlyThatField() {
assertEquals(p.maxAttempts(), q.maxAttempts());
}
+ @Test
+ void withMaxTotalDelayReplacesOnlyThatField() {
+ RetryPolicy p = RetryPolicy.defaults();
+ RetryPolicy q = p.withMaxTotalDelay(Duration.ofMinutes(5));
+ assertEquals(Duration.ofMinutes(5), q.maxTotalDelay());
+ assertEquals(p.maxDelay(), q.maxDelay());
+ assertEquals(p.maxAttempts(), q.maxAttempts());
+ assertThrows(IllegalArgumentException.class, () -> p.withMaxTotalDelay(Duration.ofSeconds(1)),
+ "a budget below maxDelay is rejected on the derived record too");
+ }
+
@Test
void withBackoffMultiplierReplacesOnlyThatField() {
RetryPolicy p = RetryPolicy.defaults();
@@ -205,6 +258,37 @@ void withMethodsRevalidate() {
RetryPolicy.defaults().withJitter(null));
}
+ // --- builder (ADR-027)
+
+ @Test
+ void builderStartsFromTheDefaults() {
+ assertEquals(RetryPolicy.defaults(), RetryPolicy.builder().build());
+ }
+
+ @Test
+ void builderSetsEveryKnob() {
+ Predicate never = e -> false;
+ RetryPolicy p = RetryPolicy.builder()
+ .maxAttempts(5)
+ .baseDelay(Duration.ofMillis(200))
+ .maxDelay(Duration.ofSeconds(10))
+ .maxTotalDelay(Duration.ofSeconds(25))
+ .backoffMultiplier(3.0)
+ .jitter(JitterStrategy.EQUAL)
+ .retryable(never)
+ .build();
+ assertEquals(new RetryPolicy(5, Duration.ofMillis(200), Duration.ofSeconds(10), Duration.ofSeconds(25),
+ 3.0, JitterStrategy.EQUAL, never), p);
+ }
+
+ @Test
+ void builderValidatesOnBuildLikeTheConstructor() {
+ assertThrows(IllegalArgumentException.class, () -> RetryPolicy.builder().maxAttempts(0).build());
+ assertThrows(IllegalArgumentException.class, () -> RetryPolicy.builder().maxTotalDelay(Duration.ofSeconds(1)).build(),
+ "below the default maxDelay of 30 s");
+ assertThrows(NullPointerException.class, () -> RetryPolicy.builder().retryable(null).build());
+ }
+
// --- isDefaultRetryable matrix (ADR-014)
@ParameterizedTest(name = "{0}")
diff --git a/core/src/test/java/qa/fanar/core/internal/retry/RetryInterceptorTest.java b/core/src/test/java/qa/fanar/core/internal/retry/RetryInterceptorTest.java
index 943ec7e..a502420 100644
--- a/core/src/test/java/qa/fanar/core/internal/retry/RetryInterceptorTest.java
+++ b/core/src/test/java/qa/fanar/core/internal/retry/RetryInterceptorTest.java
@@ -248,7 +248,7 @@ void fullJitterOnZeroBackoffYieldsZero() {
};
RetryPolicy policy = new RetryPolicy(
2,
- Duration.ofNanos(1), Duration.ofNanos(1), 1.0,
+ Duration.ofNanos(1), Duration.ofNanos(1), Duration.ofNanos(1), 1.0,
JitterStrategy.FULL, RetryPolicy::isDefaultRetryable);
new RetryInterceptor(policy, sleeper, throwingRng).intercept(baseRequest(), chain);
@@ -286,7 +286,7 @@ void equalJitterOnZeroBackoffYieldsZero() {
};
RetryPolicy policy = new RetryPolicy(
2,
- Duration.ofNanos(1), Duration.ofNanos(1), 1.0,
+ Duration.ofNanos(1), Duration.ofNanos(1), Duration.ofNanos(1), 1.0,
JitterStrategy.EQUAL, RetryPolicy::isDefaultRetryable);
new RetryInterceptor(policy, sleeper, throwingRng).intercept(baseRequest(), chain);
@@ -431,6 +431,78 @@ void predicateOptingIntoQuotaGetsTheSameHintSemantics() {
assertEquals(0, noSleep.sleepCount());
}
+ // --- total sleep budget (ADR-027)
+
+ @Test
+ void totalDelayBudgetExactlyReachedIsStillHonoured() {
+ RecordingChain chain = new RecordingChain(List.of(
+ new FanarOverloadedException("busy"),
+ new FanarOverloadedException("still busy"),
+ stubResponse()));
+ RecordingSleeper sleeper = new RecordingSleeper();
+ RetryPolicy policy = RetryPolicy.builder()
+ .jitter(JitterStrategy.NONE)
+ .baseDelay(Duration.ofMillis(100))
+ .maxDelay(Duration.ofMillis(100))
+ .maxTotalDelay(Duration.ofMillis(200))
+ .build();
+
+ new RetryInterceptor(policy, sleeper, deterministicRandom()).intercept(baseRequest(), chain);
+
+ assertEquals(List.of(Duration.ofMillis(100), Duration.ofMillis(100)), sleeper.sleeps(),
+ "two sleeps summing to exactly the budget are both taken");
+ assertEquals(3, chain.calls());
+ assertEquals(2, chain.recorder().retryCount());
+ }
+
+ @Test
+ void totalDelayBudgetExceededAbortsBeforeSleeping() {
+ FanarOverloadedException second = new FanarOverloadedException("still busy");
+ RecordingChain chain = new RecordingChain(List.of(
+ new FanarOverloadedException("busy"),
+ second,
+ stubResponse()));
+ RecordingSleeper sleeper = new RecordingSleeper();
+ RetryPolicy policy = RetryPolicy.builder()
+ .jitter(JitterStrategy.NONE)
+ .baseDelay(Duration.ofMillis(100))
+ .maxDelay(Duration.ofMillis(100))
+ .maxTotalDelay(Duration.ofMillis(150))
+ .build();
+
+ FanarOverloadedException thrown = assertThrows(FanarOverloadedException.class, () ->
+ new RetryInterceptor(policy, sleeper, deterministicRandom()).intercept(baseRequest(), chain));
+
+ assertSame(second, thrown, "the exception that would have been retried surfaces");
+ assertEquals(List.of(Duration.ofMillis(100)), sleeper.sleeps(),
+ "the second sleep would take the total to 200 ms > 150 ms: never started");
+ assertEquals(2, chain.calls(), "the attempt after the abort is never made");
+ assertEquals(List.of("retry_attempt"), chain.recorder().events(), "one retry happened, the abort is not one");
+ assertEquals(1, chain.recorder().retryCount(), "the exit is recorded like every other");
+ }
+
+ @Test
+ void retryAfterHintBeyondTheRemainingBudgetSurfacesWithTheHintPreserved() {
+ // Each hint is within maxDelay (30 s), so ADR-025 alone would sleep twice; the 30 s total
+ // budget admits the first 20 s hint and refuses the second (40 s > 30 s).
+ Duration hint = Duration.ofSeconds(20);
+ FanarRateLimitException second = new FanarRateLimitException("slow down again", hint);
+ RecordingChain chain = new RecordingChain(List.of(
+ new FanarRateLimitException("slow down", hint),
+ second,
+ stubResponse()));
+ RecordingSleeper sleeper = new RecordingSleeper();
+ RetryPolicy policy = RetryPolicy.defaults().withMaxTotalDelay(Duration.ofSeconds(30));
+
+ FanarRateLimitException thrown = assertThrows(FanarRateLimitException.class, () ->
+ new RetryInterceptor(policy, sleeper, deterministicRandom()).intercept(baseRequest(), chain));
+
+ assertSame(second, thrown);
+ assertEquals(hint, thrown.retryAfter(), "hint preserved for caller-side scheduling");
+ assertEquals(List.of(hint), sleeper.sleeps(), "only the first hint was slept");
+ assertEquals(2, chain.calls());
+ }
+
// --- rate-limit visibility (ADR-026)
@Test
diff --git a/docs/API_SKETCH.md b/docs/API_SKETCH.md
index 19b62ac..c1df68c 100644
--- a/docs/API_SKETCH.md
+++ b/docs/API_SKETCH.md
@@ -372,16 +372,25 @@ Three adapters ship: `fanar-obs-slf4j`, `fanar-obs-otel`, `fanar-obs-micrometer`
## 10. Custom retry policy
```java
+import qa.fanar.core.JitterStrategy;
import qa.fanar.core.RetryPolicy;
import java.time.Duration;
-RetryPolicy aggressive = RetryPolicy.defaults()
- .withMaxAttempts(5)
- .withBaseDelay(Duration.ofMillis(200))
- .withMaxDelay(Duration.ofSeconds(10)); // also caps honoured Retry-After hints (ADR-025)
+RetryPolicy aggressive = RetryPolicy.builder() // starts from defaults() (ADR-027)
+ .maxAttempts(5)
+ .baseDelay(Duration.ofMillis(200))
+ .maxDelay(Duration.ofSeconds(10)) // also caps honoured Retry-After hints (ADR-025)
+ .maxTotalDelay(Duration.ofSeconds(30)) // never sleep more than 30 s in total for one call
+ .build();
+
+// Bridge a full per-minute window instead of failing fast above 30 s — the budget must keep up:
+RetryPolicy patient = RetryPolicy.builder()
+ .maxDelay(Duration.ofSeconds(60))
+ .maxTotalDelay(Duration.ofMinutes(2))
+ .build();
-// Bridge a full per-minute window instead of failing fast above 30 s:
-RetryPolicy patient = RetryPolicy.defaults().withMaxDelay(Duration.ofSeconds(60));
+// One-off variants of an existing policy:
+RetryPolicy quieter = patient.withJitter(JitterStrategy.EQUAL);
FanarClient client = FanarClient.builder()
.apiKey(System.getenv("FANAR_API_KEY"))
@@ -416,6 +425,7 @@ fanar:
max-attempts: 3
initial-backoff: 500ms
max-delay: 30s # also the ceiling on honoured Retry-After hints (ADR-025)
+ max-total-delay: 1m # budget for the sum of all sleeps within one call (ADR-027)
wire-logging:
level: BASIC # NONE | BASIC | HEADERS | BODY
```
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 877a0f1..ce58045 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -208,7 +208,7 @@ Streamed TTS (`client.audio().speechStream(request)`) follows the same shape wit
| JSON codec | `FanarJsonCodec` via `.jsonCodec(codec)` or `ServiceLoader` | `ServiceLoader` discovery; **loud error** at `build()` if none found |
| Observability | `ObservabilityPlugin` via `.observability(plugin)` | No-op plugin |
| Interceptors | `.addInterceptor(i)` (registration order = chain order) | `RetryInterceptor` (outermost; also the error boundary) + `BearerTokenInterceptor` (if apiKey set) |
-| Retry policy | `.retryPolicy(policy)` | `RetryPolicy.defaults()` — 3 attempts, exponential + full jitter, 30 s cap (also the `Retry-After` ceiling, ADR-025) |
+| Retry policy | `.retryPolicy(policy)` | `RetryPolicy.defaults()` — 3 attempts, exponential + full jitter, 30 s cap (also the `Retry-After` ceiling, ADR-025), 1 min total sleep budget per call (ADR-027); build variants with `RetryPolicy.builder()` |
| User-Agent | `.userAgent(ua)` | `fanar-java/` |
| Default headers | `.defaultHeader(name, value)` (repeated) | none |
@@ -272,7 +272,7 @@ zone (ADR-018).
| Extension SPIs | `qa.fanar.core.spi` | **implemented** (FanarJsonCodec, Interceptor+Chain, ObservabilityPlugin, ObservationHandle, FanarObservationAttributes) |
| Default no-op observability | `qa.fanar.core.internal.observability` | **implemented** (NoopObservabilityPlugin, NoopObservationHandle) |
| Composite observability | `qa.fanar.core.internal.observability.CompositeObservabilityPlugin` | **implemented** — produced by `ObservabilityPlugin.compose(...)`; fans out `start` / `attribute` / `event` / `error` / `child` to N children, merges `propagationHeaders` (last-write-wins on key collision) |
-| Retry policy (public) | `qa.fanar.core.RetryPolicy` + `qa.fanar.core.JitterStrategy` | **implemented** — record + enum; validated at construction; `maxDelay` doubles as the `Retry-After` ceiling (ADR-025). The loop is `RetryInterceptor` below |
+| Retry policy (public) | `qa.fanar.core.RetryPolicy` + `qa.fanar.core.JitterStrategy` | **implemented** — record + enum + `RetryPolicy.Builder`; validated at construction; `maxDelay` doubles as the `Retry-After` ceiling (ADR-025); `maxTotalDelay` budgets the sum of one call's sleeps (ADR-027). The loop is `RetryInterceptor` below |
| HTTP transport | `qa.fanar.core.internal.transport` (`HttpTransport`, `DefaultHttpTransport`, `InterceptorChainImpl`, `ExceptionMapper`, `ErrorEnvelope`, `RateLimitHeaders`) | **implemented** — `RateLimitHeaders` is the one parser behind both `rateLimit()` and the `fanar.ratelimit.*` attributes (ADR-026) |
| Bearer-token interceptor impl | `qa.fanar.core.internal.transport.BearerTokenInterceptor` | **implemented** — per-call `Supplier` for token rotation |
| SSE parser | `qa.fanar.core.internal.sse` (`SseFrameAssembler`, `StreamEventDecoder`, `SseStreamPublisher`) | **implemented** — line-oriented accumulator, shape-routed decode, single-subscriber `Flow.Publisher` on a virtual thread |
diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md
index 15ccbf7..92b4d69 100644
--- a/docs/PROJECT_STATE.md
+++ b/docs/PROJECT_STATE.md
@@ -21,9 +21,10 @@ does where it differs from the spec, each row pinned by a live test, plus the li
the wire-log throw path (a `<-- failed` line when the chain throws, exception rethrown unchanged;
ADR-012 amended), and rate-limit visibility (ADR-026: the `fanar.ratelimit.*` observation attributes
on every response that carries the headers, `RateLimitInfo` via `rateLimit()` on both 429 exceptions,
-Micrometer's cardinality rule). Still to come in 0.4.0: `RetryPolicy` builder + total retry budget
-(ADR-027), facade plumbing consolidation, live-suite budget hygiene + nightly run, Maven Central
-readiness.
+Micrometer's cardinality rule), and the `RetryPolicy` total sleep budget + builder (ADR-027:
+`maxTotalDelay`, `fanar.retry.max-total-delay`; the canonical constructor's arity changed — the
+cycle's one breaking change). Still to come in 0.4.0: facade plumbing consolidation, live-suite
+budget hygiene + nightly run, Maven Central readiness.
## Planned
diff --git a/docs/adr/014-retry-policy-defaults.md b/docs/adr/014-retry-policy-defaults.md
index 075c55b..54318f0 100644
--- a/docs/adr/014-retry-policy-defaults.md
+++ b/docs/adr/014-retry-policy-defaults.md
@@ -1,6 +1,6 @@
# ADR-014 — Retry policy defaults
-- **Status**: Accepted (amended 2026-08-28 — see [Amendments](#amendments))
+- **Status**: Accepted (amended 2026-08-28 and 2026-08-29 — see [Amendments](#amendments))
- **Date**: 2026-04-23
- **Deciders**: @omahjoub (initial design)
@@ -151,6 +151,17 @@ retry the handshake only, never re-request mid-stream — is unchanged and now p
named in each section. This is the 0.4.0 rule: an ADR names the `*IntegrationTest` that proves what it promises
(CONTRIBUTING → Testing).
+### 2026-08-29 — a total sleep budget and a builder (0.4.0, ADR-027)
+
+The policy bounded each sleep and the number of attempts but not their sum. ADR-027 adds
+`maxTotalDelay` (default 1 min — the worst case the other defaults already allowed, so nothing
+changes at the defaults): a sleep that would push one call's cumulative sleep over the budget is
+never started, retrying ends and the exception surfaces with its hint preserved, mirroring the
+ADR-025 ceiling. The customization API gains `RetryPolicy.builder()` (from `defaults()`) and
+`withMaxTotalDelay`; the canonical constructor's arity changes — a pre-1.0 break under ADR-019,
+recorded in the changelog. The bounds that end retrying regardless of `retryable` are therefore
+three: `maxAttempts`, `maxDelay`, `maxTotalDelay`.
+
## References
- ADR-006 Unchecked exception hierarchy (typed `ErrorCode` mapping)
diff --git a/docs/adr/020-spring-boot-4-starter.md b/docs/adr/020-spring-boot-4-starter.md
index 986e6ca..48d6dc7 100644
--- a/docs/adr/020-spring-boot-4-starter.md
+++ b/docs/adr/020-spring-boot-4-starter.md
@@ -1,6 +1,6 @@
# ADR-020 — Spring Boot 4 starter shape
-- **Status**: Accepted (amended 2026-08-28 — see [Amendments](#amendments))
+- **Status**: Accepted (amended 2026-08-28 and 2026-08-29 — see [Amendments](#amendments))
- **Date**: 2026-04-26
- **Deciders**: @omahjoub
@@ -77,6 +77,15 @@ a `@ConditionalOnMissingBean RetryPolicy` bean consumed by the `FanarClient` bea
`FanarClient` bean is no longer the only escape. The sample `application.yml` and the API sketch
list the new knob.
+### 2026-08-29 — `fanar.retry.max-total-delay` (0.4.0, ADR-027)
+
+The retry budget of ADR-027 reaches Spring configuration as `fanar.retry.max-total-delay`
+(default `1m`). The `fanarRetryPolicy` bean is now built through `RetryPolicy.builder()` — the
+construction path that survives future knobs — so the four knobs validate together: a
+`max-delay` raised above the budget fails the context at startup with the policy's own message
+instead of misconfiguring the client silently. The sample `application.yml` and the API sketch
+list the knob.
+
## References
- [`fanar-spring-boot-4-starter`](../../spring-boot-4-starter/) — the module.
diff --git a/docs/adr/027-retry-policy-builder-and-budget.md b/docs/adr/027-retry-policy-builder-and-budget.md
new file mode 100644
index 0000000..d509375
--- /dev/null
+++ b/docs/adr/027-retry-policy-builder-and-budget.md
@@ -0,0 +1,106 @@
+# ADR-027 — `RetryPolicy`: a total sleep budget and a builder
+
+- **Status**: Accepted
+- **Date**: 2026-08-29
+- **Deciders**: @omahjoub
+
+## Context
+
+`RetryPolicy` bounds each sleep (`maxDelay`, also the `Retry-After` ceiling since ADR-025) and the
+number of attempts, but nothing bounds the *sum*. With the defaults the worst case is two sleeps of
+30 s; a user who raises `maxDelay` to bridge a full per-minute window (the ADR-025 recipe) and
+`maxAttempts` to five can hold the caller's thread for four minutes of sleeping, and every honoured
+`Retry-After` hint adds its full length. The SDK is sync-primary (ADR-004): that thread is the
+caller's.
+
+`RetryPolicy` is a record whose canonical constructor is public API: every knob added changes its
+arity, so adding a budget is a breaking change by construction. The `with*` methods do not help a
+first-time construction, and the starter builds its bean positionally too. Pre-1.0 (ADR-019) is
+the window in which this costs the least — the library is not yet on Maven Central.
+
+## Decision
+
+1. **`maxTotalDelay`, a new record component.** The budget for the sum of all sleeps within one
+ call. Before each sleep the retry loop checks `slept + next ≤ maxTotalDelay`, where `next` is
+ the computed back-off (jitter applied) or the honoured `Retry-After` hint; a sleep that would
+ exceed the budget is never started — retrying ends and the exception surfaces with the hint
+ preserved, exactly like the ADR-025 ceiling. A total that lands exactly on the budget is
+ honoured. The exit is observable as every other: `fanar.retry_count` is recorded, no
+ `retry_attempt` event is emitted for the refused sleep.
+2. **Default 1 minute** — the worst case the other defaults already allowed (two sleeps of at most
+ 30 s). No behaviour changes at the defaults; the budget bites only once `maxAttempts` or
+ `maxDelay` is raised.
+3. **Validation**: positive, ≥ `maxDelay` (so at least one maximal sleep fits — a budget below the
+ ceiling would make `maxDelay` unreachable), representable in milliseconds. Validated in the
+ compact constructor with the other invariants; `with*` and the builder revalidate.
+4. **`RetryPolicy.builder()`**, starting from `defaults()`, with one setter per knob and `build()`
+ validating through the canonical constructor. `withMaxTotalDelay` joins the `with*` family. The
+ builder is the recommended way to construct a policy from now on: knobs added later change the
+ canonical constructor's arity, never the builder.
+5. **The canonical constructor changes arity** (`maxTotalDelay` after `maxDelay`). **Breaking**
+ under ADR-019, called out in the changelog with the migration (use the builder, or insert the
+ new argument). No compatibility constructor: keeping the six-argument one would keep the
+ positional trap public beside the builder that exists to end it.
+6. **Starter**: `fanar.retry.max-total-delay` (default `1m`) on `FanarProperties.Retry`; the
+ `fanarRetryPolicy` bean is built through the builder so the four knobs validate together and a
+ `max-delay` raised above the budget fails the context at startup instead of silently
+ misconfiguring the client (amends ADR-020).
+
+`retryable` stays `Predicate`; an attempt- or elapsed-aware predicate is parked
+(plan, out of scope).
+
+## Alternatives considered
+
+- **Defer to the 1.0 API-freeze pass.** *Rejected*: the constructor break only gets more expensive
+ with each consumer; the feature is small and its default is invisible.
+- **Keep a six-argument compatibility constructor.** *Rejected*: it preserves the positional
+ constructor that made every knob a breaking change, next to the builder introduced to end that.
+ ADR-019 exists for exactly this kind of break.
+- **A wall-clock deadline including request time** (`maxElapsed`). *Rejected*: request time is
+ governed by the transport's connect and request timeouts; folding it into the retry budget
+ double-counts and makes the guarantee depend on the server's latency rather than the SDK's own
+ sleeping.
+- **Clamp the last sleep to the remaining budget instead of refusing it.** *Rejected*: it would
+ retry a `Retry-After` hint early — the same premature re-request ADR-025 refused.
+- **Count the budget across calls (a client-wide token bucket).** *Rejected*: policy the SDK should
+ not choose; a user interceptor with the ADR-026 data can implement one.
+
+## Consequences
+
+### Positive
+- An upper bound on the time one call spends sleeping, independent of how the other knobs are set
+ and of how many hints the server sends.
+- A stable construction path (`builder()`) for every future knob.
+
+### Negative / Trade-offs
+- The canonical constructor break: positional `new RetryPolicy(...)` calls need a seventh argument
+ or a move to the builder. Zero known external consumers at the time of the change.
+- One more invariant (`maxTotalDelay ≥ maxDelay`): raising `maxDelay` past 1 min now requires
+ raising the budget too — deliberate, and validated loudly at construction (and at Spring startup).
+
+### Neutral
+- `RetryPolicy.disabled()` carries the default budget like every other unused knob.
+- `equals` / `hashCode` include the new component; `defaults()` stays equal to
+ `builder().build()`.
+
+## Proved by
+
+- `FanarClientRetryIntegrationTest.retryAfterHintsBeyondTheTotalBudgetEndRetrying` — two 429s with
+ `Retry-After: 1` under a 1 s ceiling and a 1 s budget through the public builder: the first hint
+ is slept (exactly the budget), the second is refused, the exception surfaces with the hint, no
+ further request is made.
+- `RetryInterceptorTest.totalDelayBudgetExactlyReachedIsStillHonoured`,
+ `.totalDelayBudgetExceededAbortsBeforeSleeping`,
+ `.retryAfterHintBeyondTheRemainingBudgetSurfacesWithTheHintPreserved`.
+- `RetryPolicyTest` — validation (`rejects*MaxTotalDelay*`), `withMaxTotalDelayReplacesOnlyThatField`,
+ the `builder*` cases (`defaults()` equals `builder().build()`).
+- `FanarAutoConfigurationTest` — the knob's default and override, `retryKnobsAreValidatedTogetherAtStartup`.
+
+## References
+
+- ADR-004 Sync-primary API with async sugar (why a sleeping thread is the caller's)
+- ADR-014 Retry policy defaults (amended 2026-08-29 by this record)
+- ADR-019 Pre-1.0 stability policy (the constructor break)
+- ADR-020 Spring Boot 4 starter shape (amended 2026-08-29: `fanar.retry.max-total-delay`)
+- ADR-025 Retry-After handling (the per-sleep ceiling this budget complements)
+- ADR-026 Rate-limit visibility (the data a client-wide throttle would use instead)
diff --git a/docs/adr/INDEX.md b/docs/adr/INDEX.md
index 506b590..e420054 100644
--- a/docs/adr/INDEX.md
+++ b/docs/adr/INDEX.md
@@ -48,6 +48,7 @@ Every ADR follows an extended Michael Nygard template:
- [014 — Retry policy defaults](014-retry-policy-defaults.md)
- [025 — Retry-After handling: ceiling, normalisation, and the quota hint](025-retry-after-handling.md)
- [026 — Rate-limit visibility: observation attributes and `RateLimitInfo` on the 429s](026-rate-limit-telemetry.md)
+- [027 — `RetryPolicy`: a total sleep budget and a builder](027-retry-policy-builder-and-budget.md)
### Build, distribution, governance
diff --git a/spring-boot-4-sample/src/main/resources/application.yml b/spring-boot-4-sample/src/main/resources/application.yml
index d1df5ae..f4503a7 100644
--- a/spring-boot-4-sample/src/main/resources/application.yml
+++ b/spring-boot-4-sample/src/main/resources/application.yml
@@ -10,6 +10,7 @@ fanar:
# max-attempts: 3
# initial-backoff: 500ms
# max-delay: 30s # also the ceiling on honoured Retry-After hints (ADR-025)
+ # max-total-delay: 1m # budget for the sum of all sleeps within one call (ADR-027)
wire-logging:
level: BASIC # NONE | BASIC | HEADERS | BODY — auto-wires the WireLoggingInterceptor
diff --git a/spring-boot-4-starter/src/main/java/qa/fanar/spring/boot/v4/FanarAutoConfiguration.java b/spring-boot-4-starter/src/main/java/qa/fanar/spring/boot/v4/FanarAutoConfiguration.java
index e047c6a..c4d1fc0 100644
--- a/spring-boot-4-starter/src/main/java/qa/fanar/spring/boot/v4/FanarAutoConfiguration.java
+++ b/spring-boot-4-starter/src/main/java/qa/fanar/spring/boot/v4/FanarAutoConfiguration.java
@@ -54,21 +54,20 @@ FanarJsonCodec fanarJsonCodec() {
* Retry policy from the {@code fanar.retry.*} knobs on top of {@link RetryPolicy#defaults()}.
* Replaced if the user declares their own {@link RetryPolicy} bean — the route to a custom
* {@code retryable} predicate, jitter strategy or multiplier without re-wiring the client.
- * Built through the canonical constructor so the three knobs are validated together (a
- * raised {@code initial-backoff} needs a {@code max-delay} at least as large).
+ * Built through {@link RetryPolicy#builder()} so the four knobs are validated together (a
+ * raised {@code initial-backoff} needs a {@code max-delay} at least as large, and a raised
+ * {@code max-delay} a {@code max-total-delay} at least as large — ADR-027).
*/
@Bean
@ConditionalOnMissingBean
RetryPolicy fanarRetryPolicy(FanarProperties props) {
FanarProperties.Retry retry = props.retry();
- RetryPolicy defaults = RetryPolicy.defaults();
- return new RetryPolicy(
- retry.maxAttempts(),
- retry.initialBackoff(),
- retry.maxDelay(),
- defaults.backoffMultiplier(),
- defaults.jitter(),
- defaults.retryable());
+ return RetryPolicy.builder()
+ .maxAttempts(retry.maxAttempts())
+ .baseDelay(retry.initialBackoff())
+ .maxDelay(retry.maxDelay())
+ .maxTotalDelay(retry.maxTotalDelay())
+ .build();
}
@Bean
diff --git a/spring-boot-4-starter/src/main/java/qa/fanar/spring/boot/v4/FanarProperties.java b/spring-boot-4-starter/src/main/java/qa/fanar/spring/boot/v4/FanarProperties.java
index bda6d52..0b5daf0 100644
--- a/spring-boot-4-starter/src/main/java/qa/fanar/spring/boot/v4/FanarProperties.java
+++ b/spring-boot-4-starter/src/main/java/qa/fanar/spring/boot/v4/FanarProperties.java
@@ -39,7 +39,7 @@ public record FanarProperties(
/**
* Retry policy knobs. Maps to {@link qa.fanar.core.RetryPolicy}; the defaults are the SDK's
- * own ({@code RetryPolicy.defaults()}). Anything beyond these three — jitter, multiplier, a
+ * own ({@code RetryPolicy.defaults()}). Anything beyond these four — jitter, multiplier, a
* custom retryable predicate — is a {@code RetryPolicy} bean, see
* {@link FanarAutoConfiguration}.
*
@@ -50,11 +50,15 @@ public record FanarProperties(
* @param maxDelay cap on the computed backoff and ceiling on honoured server
* {@code Retry-After} hints — defaults to 30s; a hint above it ends
* retrying and the exception surfaces with the hint preserved (ADR-025)
+ * @param maxTotalDelay budget for the sum of all sleeps within one call — defaults to 1m; a
+ * sleep that would exceed it is never taken and the exception surfaces
+ * with the hint preserved (ADR-027); must be at least {@code maxDelay}
*/
public record Retry(
@DefaultValue("3") int maxAttempts,
@DefaultValue("500ms") Duration initialBackoff,
- @DefaultValue("30s") Duration maxDelay
+ @DefaultValue("30s") Duration maxDelay,
+ @DefaultValue("1m") Duration maxTotalDelay
) { }
/**
diff --git a/spring-boot-4-starter/src/test/java/qa/fanar/spring/boot/v4/FanarAutoConfigurationTest.java b/spring-boot-4-starter/src/test/java/qa/fanar/spring/boot/v4/FanarAutoConfigurationTest.java
index a51947b..ba3c9be 100644
--- a/spring-boot-4-starter/src/test/java/qa/fanar/spring/boot/v4/FanarAutoConfigurationTest.java
+++ b/spring-boot-4-starter/src/test/java/qa/fanar/spring/boot/v4/FanarAutoConfigurationTest.java
@@ -50,6 +50,7 @@ void defaultsApply() {
assertThat(props.retry().maxAttempts()).isEqualTo(3);
assertThat(props.retry().initialBackoff().toMillis()).isEqualTo(500);
assertThat(props.retry().maxDelay().toSeconds()).isEqualTo(30);
+ assertThat(props.retry().maxTotalDelay()).isEqualTo(Duration.ofMinutes(1));
assertThat(props.wireLogging().level().name()).isEqualTo("NONE");
});
}
@@ -64,6 +65,7 @@ void yamlOverridesAreApplied() {
"fanar.retry.max-attempts=5",
"fanar.retry.initial-backoff=250ms",
"fanar.retry.max-delay=45s",
+ "fanar.retry.max-total-delay=5m",
"fanar.wire-logging.level=BODY")
.run(ctx -> {
FanarProperties props = ctx.getBean(FanarProperties.class);
@@ -73,6 +75,7 @@ void yamlOverridesAreApplied() {
assertThat(props.retry().maxAttempts()).isEqualTo(5);
assertThat(props.retry().initialBackoff().toMillis()).isEqualTo(250);
assertThat(props.retry().maxDelay().toSeconds()).isEqualTo(45);
+ assertThat(props.retry().maxTotalDelay()).isEqualTo(Duration.ofMinutes(5));
assertThat(props.wireLogging().level().name()).isEqualTo("BODY");
});
}
@@ -85,23 +88,40 @@ void defaultRetryPolicyBeanMirrorsTheSdkDefaults() {
@Test
void retryPolicyBeanReflectsTheRetryKnobs() {
- // initial-backoff above the SDK's default cap used to fail at startup; the three knobs are
- // now validated together.
+ // initial-backoff above the SDK's default cap used to fail at startup; the four knobs are
+ // now validated together (a max-delay of 90 s also needs max-total-delay >= 90 s).
runner.withPropertyValues(
"fanar.api-key=test-key",
"fanar.retry.max-attempts=5",
"fanar.retry.initial-backoff=45s",
- "fanar.retry.max-delay=90s")
+ "fanar.retry.max-delay=90s",
+ "fanar.retry.max-total-delay=10m")
.run(ctx -> {
RetryPolicy policy = ctx.getBean(RetryPolicy.class);
assertThat(policy.maxAttempts()).isEqualTo(5);
assertThat(policy.baseDelay()).isEqualTo(Duration.ofSeconds(45));
assertThat(policy.maxDelay()).isEqualTo(Duration.ofSeconds(90));
+ assertThat(policy.maxTotalDelay()).isEqualTo(Duration.ofMinutes(10));
assertThat(policy.jitter()).isEqualTo(RetryPolicy.defaults().jitter());
assertThat(policy.backoffMultiplier()).isEqualTo(RetryPolicy.defaults().backoffMultiplier());
});
}
+ @Test
+ void retryKnobsAreValidatedTogetherAtStartup() {
+ // max-delay raised above the default 1 m budget without raising max-total-delay: the
+ // policy's own validation fails the context, loudly, instead of a silent mis-configuration.
+ runner.withPropertyValues(
+ "fanar.api-key=test-key",
+ "fanar.retry.max-delay=2m")
+ .run(ctx -> {
+ assertThat(ctx).hasFailed();
+ assertThat(ctx.getStartupFailure()).rootCause()
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("maxTotalDelay");
+ });
+ }
+
@Test
void userDefinedRetryPolicyBeanWins() {
runner.withPropertyValues("fanar.api-key=test-key", "fanar.retry.max-attempts=7")