Files
tech-log-backend/docs/httpclient/retry-and-ambiguity.md
T

72 lines
3.3 KiB
Markdown

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