Skip to content

refactor: reconcile retry defaults and backoff across the two retry steps#97

Merged
OmarAlJarrah merged 5 commits into
mainfrom
feat/retry-defaults-reconcile
Jun 16, 2026
Merged

refactor: reconcile retry defaults and backoff across the two retry steps#97
OmarAlJarrah merged 5 commits into
mainfrom
feat/retry-defaults-reconcile

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

The two retry stacks — the stage-based DefaultRetryStep and the recovery-aware RetryStep — had drifted apart: different defaults (base delay 800 ms vs 200 ms, ±5% asymmetric vs ±10% symmetric jitter, 4 vs 3 total sends) and two separate backoff formulas, and they disagreed on whether 408 is retryable.

This reconciles them:

  • BackoffCalculator (deadline-aware, symmetric jitter) becomes the single backoff implementation; the stage-based step now computes its delay through it, and the duplicate shift-based formula and its jitter helper are removed.
  • The canonical defaults are promoted to shared constants on RetrySettings (initial 200 ms, max 8 s, multiplier 2.0, ±10% jitter, 3 total sends) and both stacks reference them, so they can't silently drift again.
  • 408 is retryable on both, matching the existing status classifier.

Tests pin the now-shared defaults and the backoff sequence on both stacks so future drift fails the build; the API snapshot adds the shared constants and drops a now-dead one.

Closes #38

… and server-driven retries

Three additive, opt-in enhancements to the retry layer:

- Parse fractional Retry-After values. RetryAfterParser now reads the
  Retry-After delta-seconds form with toDoubleOrNull, so values like
  `Retry-After: 1.5` are honoured to nanosecond resolution instead of being
  silently ignored. Integer seconds and the HTTP-date form are unchanged, and
  the negative / NaN / non-finite guards plus the 365-day ceiling still apply.
  The numeric form now shares that ceiling with the date and Unix-epoch forms,
  so a far-future delta clamps rather than flowing through unbounded. Both
  retry layers benefit, since they delegate parsing here.

- Optional per-attempt retry-count request header. RetrySettings gains an
  opt-in attemptHeaderName (default null). When set, the recovery-aware
  RetryStep stamps that header with the 1-based attempt ordinal on a per-attempt
  copy of the request, so servers and proxies can observe the retry count. The
  header is applied via Request.newBuilder, leaving the immutable template and
  any caller-supplied idempotency key untouched.

- Server-driven retry predicate. New ServerOverrideRetryPredicate implements the
  composable HttpRetryConditionPredicate seam and honours an X-Should-Retry-style
  response header (configurable name): a truthy value forces a retry, a falsy
  value suppresses one, and an absent or unrecognised value defers to a delegate
  predicate. It is not installed by default; the default retryable-status set is
  unchanged, so 409 stays out of it unless a server explicitly opts a response in.
The stage-based DefaultRetryStep and the recovery-aware RetryStep shipped
divergent retry defaults and two different backoff formulas. DefaultRetryStep
used an 800ms base delay, a hard-coded `1L shl tryCount` schedule, and ±5%
asymmetric jitter; RetryStep used a 200ms initial delay, a configurable 2.0
multiplier, and 0.2 symmetric jitter via BackoffCalculator. They also
disagreed on retryable statuses: RetryUtils (and HttpException.retryable)
treat 408 as retryable, but RetrySettings dropped it from the default set, so
the two stacks retried different responses.

Reconcile both around a single source of truth:

- BackoffCalculator is now the only backoff implementation. DefaultRetryStep
  computes its exponential delay through it (via a RetrySettings view built
  from the options plus the shared multiplier/jitter constants), with the
  total-timeout deadline disabled since the stage-based step carries no
  budget. The duplicate shift-based formula, its jitter helper, and the now
  dead MAX_SHIFT_TRY_COUNT constant are removed.
- The canonical defaults live as public constants on RetrySettings
  (DEFAULT_DELAY_MULTIPLIER, DEFAULT_JITTER, DEFAULT_MAX_ATTEMPTS, plus the
  existing delay constants). HttpRetryOptions references them, so its base
  delay (200ms), max delay (8s), and retry budget can no longer drift. Both
  stacks now default to 3 total sends (HttpRetryOptions.maxRetries=2,
  RetrySettings.maxAttempts=3).
- 408 is retryable by default everywhere: it is added to
  RetrySettings.DEFAULT_RETRYABLE_STATUSES, matching RetryUtils and the
  stage-based classifier.

Updates KDoc and docs/pipelines.md to state the shared values, adds tests
pinning the defaults and the backoff sequence on both stacks, and refreshes
the existing jitter/clamp assertions for the unified ±10% jitter. Regenerates
the sdk-core API snapshot for the constant changes.
…y validation

Stop the recovery-aware 408 test from leaking a single-thread scheduled
executor: with zero delays the step never schedules a deferred wait, so omit
the caller-supplied scheduler and let RetryStep route through its process-wide
lazy daemon scheduler (which is never leaked per run). RetryStep does not own
or shut down a caller-supplied scheduler, so constructing one inline left a
non-daemon thread per test run.

Replace the inline fully-qualified references (assertions, Executors,
HttpException, Protocol, Method, Clock, Instant) with imports to match the rest
of the suite, and add a test pinning that constructing DefaultRetryStep now
validates delay magnitudes eagerly. Document that eager validation on the
backoffSettings field and smooth the delay-precedence KDoc phrasing.
@OmarAlJarrah OmarAlJarrah changed the base branch from feat/retry-controls to main June 16, 2026 20:16
…econcile

# Conflicts:
#	sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate.kt
#	sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryAfterParser.kt
#	sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryStepTest.kt
#	sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryAfterParserTest.kt
@OmarAlJarrah OmarAlJarrah merged commit 0efad1e into main Jun 16, 2026
1 check passed
@OmarAlJarrah OmarAlJarrah deleted the feat/retry-defaults-reconcile branch June 16, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reconcile divergent retry defaults and backoff formulas

1 participant