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:
- attempts exhausted →
RetryDenied.maxAttempts() - retry budget empty →
RetryDenied.budgetExhausted() - body not replayable →
RetryDenied.bodyNotReplayable() - first byte already delivered →
RetryDenied.responseAlreadyDelivered() - runtime draining →
RetryDenied.runtimeDraining() - remaining deadline below the minimum attempt budget →
RetryDenied.deadline() - permanent failure category →
RetryDenied.permanentFailure(...) SENT_NO_RESPONSEon an operation that is not safely idempotent →AmbiguousFailure- 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.