Safe Retry Design in Critical Systems
A retry design guide covering failure classification, idempotency, layered amplification, backoff, jitter, deadlines, and traffic budgets.
A failed request does not make an immediate retry safe or useful. A retry can succeed when the failure came from a temporary network interruption. When the target is already overloaded, extra requests increase the pressure on the system that is trying to recover.
Let the initial request rate be λ₀, the probability that each attempt fails be p, and the maximum number of attempts be A. Under the simplifying assumption that failures are independent, the expected number of physical attempts for one logical request is:
E[N] = 1 + p + p² + ... + p^(A-1)
For p ≠ 1:
E[N] = (1 - p^A) / (1 - p)
The effective request load grows approximately as:
λeffective = λ₀ × E[N]
If three attempts are permitted and the failure probability is 0.2:
E[N] = 1 + 0.2 + 0.04 = 1.24
The retry policy creates 24 percent more load than normal traffic.
At a failure probability of 0.9:
E[N] = 1 + 0.9 + 0.81 = 2.71
Clients begin generating roughly 2.7 times the normal load at the moment when system capacity has already declined.
The formula assumes independent failures. Overload failures are usually correlated because requests sent in the same interval share the same constrained capacity. Retrying can then prolong the incident instead of reducing the probability of failure.
Which Failures Should Be Retried?
The presence of an error code is not enough to justify a retry. A failure should be classified at least as temporary, permanent, or outcome-unknown.
A temporary failure may succeed when the same request is sent later:
- Connection interruption
- Temporary service unavailability
- Short-lived lock conflict
- Transient capacity limit
A permanent failure is expected to produce the same result while the request remains unchanged:
- Invalid parameter
- Missing authorization
- Unsupported operation
- Malformed data
- Unmet precondition
gRPC expresses part of this distinction through its status model. UNAVAILABLE is intended for transient conditions in which the failed call can be retried with backoff. FAILED_PRECONDITION indicates that the system state must be corrected before another attempt. ABORTED can mean that the complete higher-level read-modify-write sequence must restart. The same documentation also warns that repeating a non-idempotent operation is not always safe.
An outcome-unknown failure is harder. The client may time out even though the server completed the operation and only the response was lost:
client -> request sent
server -> operation completed
network -> response lost
client -> timeoutThe operation appears to have failed from the client's perspective, while its side effect already exists on the server. Sending the request again can apply the operation twice.
timeout != operation not appliedA timeout only means that the client did not learn the result within the allowed interval. AWS guidance for distributed systems similarly notes that a timeout or connection failure does not prove that a side effect was absent. Safe retry therefore requires an idempotent API design.
Retrying an Idempotent Operation
An operation is idempotent when applying the same logical request more than once leaves the intended final server state equal to a single application:
f(f(x)) = f(x)
This notation describes application semantics. It does not necessarily mean that every physical side effect occurred only once.
Writing the same value to a known key can be idempotent:
UPDATE CONFIGURATION
SET VALUE = ?
WHERE CONFIG_KEY = ?Repeating the statement does not change the final state.
The following operation is not naturally idempotent:
UPDATE ACCOUNT
SET BALANCE = BALANCE - ?
WHERE ACCOUNT_ID = ?A second execution reduces the balance again.
In HTTP semantics, an idempotent method is one for which repeated application of the same request has the same intended effect on the server as one application. RFC 9110 states that a client should automatically retry a non-idempotent request only when it knows the operation is idempotent at the application level or can determine that the original request was not applied.
A naturally non-idempotent operation can be protected with an idempotency key:
requestId = 7f613a...The server stores a record that remains constant across all attempts of the same logical operation:
(requestId, requestHash, result, status)When the same requestId arrives again:
- If the first operation completed, the previous result is returned.
- If the first operation is still running, no second operation is started.
- If the same key is used with different content, the request is rejected.
- If the first attempt did not commit, the operation can be restarted under a defined policy.
Generating a new key for every retry removes the protection:
wrong:
attempt 1 -> requestId A
attempt 2 -> requestId B
correct:
logical operation -> requestId A
attempt 1 -> requestId A
attempt 2 -> requestId AThe retention period of an idempotency record must not be shorter than the retry window. If a client can retry for one hour but the server forgets the key after five minutes, a delayed retry can create a second operation.
Retry Amplification Across Layers
Consider this call chain:
client
-> API
-> business service
-> data service
-> repository
-> databaseIf every layer makes three attempts, the maximum number of calls reaching the database is:
Amax = 3⁵ = 243
One user request can expand into 243 physical calls at the lowest dependency. The AWS Builders' Library uses the same five-layer example to show why retry should usually be implemented at one architectural layer.
This effect is called retry amplification. With d dependency layers and A attempts at each layer:
maximum downstream calls = A^dRetries at every layer do more than multiply load. When an upper layer restarts, useful work already completed below can be discarded and repeated:
upper-level timeout
-> restart the whole workflow
-> repeat previously successful subcallsRetry ownership should therefore be assigned explicitly. Lower layers should return detailed, classifiable failures. The layer that knows the total deadline, idempotency rule, and business context should manage the retry budget.
Some operations need only the lowest-level call to be retried. A transaction conflict may require the entire read-modify-write sequence to restart, because repeating one SQL statement is not sufficient. The abstraction level of the retry is as important as the error code.
Backoff Spreads Load but Does Not Remove It
A fixed retry interval may be adequate for short transient failures:
d₁ = 2 s
d₂ = 2 s
d₃ = 2 sWhen many clients fail at the same moment, they also return together:
t = 0 -> correlated failure
t = 2 -> correlated retry
t = 4 -> second correlated retryExponential backoff increases delay with the attempt number:
dₖ = min(dmax, d₀ × b^k)Where:
d₀is the initial delay.bis the growth factor.kis the retry attempt index.dmaxis the maximum delay.
For:
d₀ = 1 second
b = 2The delays become:
1, 2, 4, 8, 16, 32 ...Without a cap, the wait can grow beyond a useful operational range. With a cap, all clients can eventually settle into the same fixed period:
32, 32, 32, 32 ...Backoff alone does not remove synchronization. AWS also notes that capped exponential backoff can leave clients retrying in lockstep at the cap.
Backoff does not create capacity either. If the target serves 100 requests per second while sustained demand is 150 requests per second, delaying calls only changes the shape of the queue:
λarrival > μserviceThe backlog continues to grow. Backoff helps only when usable target capacity has returned by the time the delayed attempt is sent.
Jitter and Deterministic Distribution
Thousands of clients using the same backoff formula remain correlated when they start from the same failure. Jitter adds controlled dispersion to the calculated delay.
Full jitter can be written as:
capₖ = min(dmax, d₀ × 2^k)
delayₖ ~ Uniform(0, capₖ)Equal jitter keeps half the delay fixed and randomizes the other half:
delayₖ = capₖ / 2 + Uniform(0, capₖ / 2)The purpose is to stop clients from returning in the same millisecond. AWS explains that synchronized backoff preserves contention, while jitter distributes attempts over time.
Pure randomness is not equally suitable for every system. Where incidents must be replayable and timing patterns observable, jitter can be generated deterministically:
seed = hash(clientId, logicalRequestId, attempt)Then:
delay = deterministicUniform(seed, 0, cap)Different clients spread across the interval, while replaying the same logical request for the same client produces the same delay sequence. AWS makes a related recommendation for periodic jobs: stable host-specific distribution can be operationally easier to observe than entirely new random jitter on every run.
A single-process, single-thread task does not always need jitter. If one database connection performs three sequential attempts, a deterministic sequence such as:
0 seconds
2 seconds
10 secondsmay be sufficient. Jitter matters most when independent clients or scheduler instances can load the same dependency at the same time.
Timeout, Deadline, and Retry Budget
Per-attempt timeouts do not prevent the complete operation from extending too far:
attempt timeout = 30 seconds
attempt count = 3
backoff = 2 + 10 secondsThe approximate total duration is:
30 + 2 + 30 + 10 + 30 = 102 secondsIf the external contract allows only 60 seconds, starting the third attempt has no value. A retry system must manage a total deadline, not just an attempt count:
remaining =
deadline -
currentTimeA new attempt should begin only when:
remaining >
backoff +
minimumUsefulAttemptTimeOtherwise, the client starts a call that cannot produce a useful result and consumes target capacity unnecessarily.
gRPC defines the deadline as the absolute point after which the client no longer waits for a result. Without a configured deadline, a call may wait indefinitely. Propagating the remaining deadline through chained RPCs prevents lower services from continuing work for an upper request that has already expired.
A retry budget should cover these constraints together:
- Maximum number of attempts
- Total deadline
- Total backoff time
- Maximum permitted retry traffic
A policy limited only by attempt count can create unacceptable latency when timeouts are long. A policy limited only by total time can send too many requests when per-attempt timeouts are very short.
Token Buckets for Retry Traffic
When a service starts failing, permitting every request to make the same number of attempts makes retry traffic grow with the error rate. A shared retry budget can limit this growth.
A token bucket can be configured as:
capacity = B
refill rate = r tokens/second
retry cost = 1 tokenEach retry consumes one token. When the bucket is empty:
- Original requests can still be sent normally.
- Retries are rejected or limited to a low fixed rate.
- Part of the target's capacity remains available for recovery.
This prevents retries from exceeding a defined share of primary traffic. AWS describes using a local token bucket to throttle retries after tokens are exhausted, avoiding some of the abrupt mode changes associated with circuit breakers.
A simpler ratio budget is:
retryRate ≤ α × originalRequestRateFor α = 0.1, retry traffic cannot exceed 10 percent of original traffic.
As the failure rate rises, some requests then fail without a retry. This may appear to reduce availability, but protecting a collapsing dependency can allow more original requests to succeed overall.
A Retry Policy Is More Than a Schedule
The following is not a complete retry policy:
attempt 1 -> immediately
attempt 2 -> after 2 seconds
attempt 3 -> after 10 secondsIt is only a delay sequence. A complete policy must answer these questions precisely:
- Which failure classes are retryable?
- Is the operation idempotent?
- How is the same logical operation identified?
- At which architectural layer is retry performed?
- What is the total deadline?
- What is the timeout for each attempt?
- How are backoff and jitter calculated?
- Which budget limits retry traffic?
- Are server pushback and
Retry-Afterhonored? - How are failed retries observed?
The built-in gRPC retry mechanism also evaluates more than the existence of an error. It combines retryable status codes, an attempt limit, and exponential backoff. Once response headers are received, it treats the call as committed and stops further automatic retries. It also supports retry throttling and server pushback.
Useful metrics extend beyond final success rate:
- First-attempt success rate
- Success rate after retry
- Distribution of attempt counts
- Retries by failure class
- Total retry traffic
- Added latency caused by retries
- Attempts cancelled by the deadline
- Idempotency-key conflicts
- Age of the oldest pending operation
If first-attempt success falls while total success remains stable, retries may be hiding infrastructure degradation. If post-retry success also falls, extra attempts are producing load without meaningful recovery.
Retry is not an automatic synonym for fault tolerance. It can improve availability for correctly classified transient failures and idempotent operations. Under overload, permanent failures, or uncertain side effects, it can turn into a distributed traffic attack generated by the system against itself.
The objective in a critical system is not to attempt every request as many times as possible. Each retry should still have a credible chance of success and must not reduce the total recovery capacity of the system.