Files
clean-architecture-backend-…/docs/httpclient/retry-and-ambiguity.md
T
DongHyeonkaandClaude Opus 5 5f10b791d3 chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:48:43 +09:00

3.3 KiB

Retry and Ambiguity

The platform never decides a retry from the HTTP method alone (design D-09). A second attempt happens only when idempotency, body replayability, execution evidence, deadline, and retry budget all permit it.

Execution evidence

Evidence Meaning Typical cause
NOT_SENT Proven that the server never received the request profile rejection, pool timeout, DNS failure, connect failure, pre-request TLS failure, HTTP/2 REFUSED_STREAM
SENT_NO_RESPONSE Some or all of the request was written, no final header arrived partial write, response-header timeout, connection reset
RESPONSE_RECEIVED Final headers arrived, whatever the status 2xx, 4xx, 5xx, redirect
PARTIAL_RESPONSE Headers and part of the body arrived reset during decode, interrupted stream

NOT_SENT is only produced by a stage failure that proves it. A generic engine I/O error is never upgraded to NOT_SENT, because that is exactly how a timeout becomes a duplicate payment.

Body replayability

Body Replayability
immutable byte[] REPLAYABLE
DTO plus a deterministic codec REPLAYABLE
reopenable file or resource supplier REOPENABLE
a single InputStream instance ONE_SHOT
publisher factory as declared
publisher instance ONE_SHOT
multipart the weakest part

Decision order

DefaultRetryEligibilityEngine evaluates in this order, and a later rule can never re-enable something an earlier one forbade:

  1. attempts exhausted → RetryDenied.maxAttempts()
  2. retry budget empty → RetryDenied.budgetExhausted()
  3. body not replayable → RetryDenied.bodyNotReplayable()
  4. first byte already delivered → RetryDenied.responseAlreadyDelivered()
  5. runtime draining → RetryDenied.runtimeDraining()
  6. remaining deadline below the minimum attempt budget → RetryDenied.deadline()
  7. permanent failure category → RetryDenied.permanentFailure(...)
  8. SENT_NO_RESPONSE on an operation that is not safely idempotent → AmbiguousFailure
  9. status- and failure-specific rules

Status rules

Status Decision
408 retry inside deadline and budget
425 at most one retry, first attempt only
429 retry inside Retry-After, deadline, and budget
401 one refresh-and-replay, safe replayable operations only
500 denied unless the upstream registered it as transient and the operation is safely idempotent
502, 503, 504 retry for safely idempotent operations; ambiguous otherwise
other 4xx denied

Ambiguity

A non-idempotent request that reached SENT_NO_RESPONSE raises HttpAmbiguousExecutionException. It is a third answer on purpose: retrying may duplicate a side effect, and reporting a plain failure would tell the caller the request did not happen, which may be false. The caller reconciles, usually by querying the upstream or replaying with an idempotency key.

Budget and backoff

Retry tokens come from a per-upstream token bucket sized as a fraction of real traffic, so a failing upstream cannot be flooded by retries from a healthy fleet. Backoff is exponential with full or decorrelated jitter, bounded by max-backoff, by Retry-After, and by the remaining deadline. No connection and no bulkhead permit is held while a backoff is waiting.