# 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.