chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user