Retry Strategy #
In distributed systems, not every failure means something is fundamentally wrong. Most failures are transient — a briefly exhausted database connection, an external API riding out a traffic spike, a DNS lookup that fails once but succeeds on the next try. If a system gives up on the first failure, many operations that should have succeeded turn into unnecessary errors. But if the system retries without a proper strategy — no limits, no delay, no consideration of error type — the retry itself can become the problem: bombarding a downstream that’s struggling to recover, or executing a non-idempotent operation twice with unwanted consequences. This article covers retry strategies thoroughly: six retry types with their characteristics and trade-offs, concrete Go and Dart implementations, classifying errors that should and shouldn’t be retried, and integration with circuit breakers and DLQs.
What Is a Retry Strategy? #
A Retry Strategy is a systematic approach to retrying failed operations, with clear rules and limits. Not just a loop that repeats an operation until it succeeds, but a mechanism that’s aware of when a retry makes sense, how long the delay should be, and when to give up.
// ANTI-PATTERN: retry without a strategy — dangerous
func callServiceDangerous() error {
for { // ← no limit
err := callService()
if err == nil { return nil }
// no delay — DDoS-ing yourself
// no error classification — retrying validation errors that will never change
}
}
// CORRECT: retry with explicit configuration
func callServiceSafe(ctx context.Context) error {
return RetryWithBackoff(ctx, RetryConfig{
MaxAttempts: 5,
BaseDelay: 100 * time.Millisecond,
MaxDelay: 30 * time.Second,
Jitter: true,
IsRetryable: isTransientError,
}, func() error {
return callService()
})
}
// 4xx errors → fail fast without retry
// 5xx/timeout errors → retry with increasing delay
// After max attempts → return the final error
flowchart TD
A[Operation Failed] --> B{IsRetryable?}
B -- No\n4xx, validation --> C["Fail Fast\nReturn Error"]
B -- Yes\n5xx, timeout, 429 --> D{"Attempt <=\nMaxAttempts?"}
D -- No --> E["Exhausted\nSend to DLQ\nor Return Error"]
D -- Yes --> F["Calculate Delay\nexponential + jitter"]
F --> G[Wait for Delay]
G --> H[Retry Operation]
H --> I{Successful?}
I -- Yes --> J[Success ✓]
I -- No --> BWhy Retry Strategy Matters #
There are three fundamental reasons retry isn’t optional in modern systems.
Transient failures are the norm, not the exception. Network timeouts, DNS hiccups, temporary service overload, serverless cold starts, lock contention that releases quickly — these are all failures that heal themselves within milliseconds to seconds. Without retry, every transient failure becomes a visible error the caller must handle or, worse, noise that floods the alerts.
Retry is the foundation of resilience patterns. Circuit breakers, bulkheads, and timeouts all assume a retry mechanism behind them. Once a circuit closes again after its cooldown period, the system must be able to retry the operation that previously failed. Without proper retry, these patterns are less effective.
Reliability without manual intervention. Systems that can’t self-heal from transient failures require on-call engineers to respond to every minor incident. A well-designed retry reduces the incident rate and makes the system more autonomous.
Six Retry Strategy Types #
1. Immediate Retry #
Retry with no delay at all. Only suitable for in-memory operations or cases where the failure is truly one-off and heals within microseconds.
// ANTI-PATTERN: immediate retry for network calls
for attempt := 0; attempt < maxAttempts; attempt++ {
err := callExternalService()
if err == nil { return nil }
// ← no delay
// → when the downstream is down for 100ms, all retries flood it simultaneously
}
// CORRECT: immediate retry only for truly microsecond in-memory operations
for attempt := 0; attempt < 3; attempt++ {
if acquired := mutex.TryLock(); acquired {
return nil
}
// mutex contention heals in nanoseconds, immediate retry is safe here
runtime.Gosched() // yield CPU, try again immediately
}
Don’t use immediate retry for network calls or I/O operations. If the downstream is down for 100ms, every instance experiencing the error at the same time will hit the downstream simultaneously when they retry — making an already overloaded situation worse.
2. Fixed Delay Retry #
Retry with the same wait time every time. Safer than immediate retry but still prone to thundering herds.
// Fixed delay — simple, fine for simple single-instance systems
func retryFixed(ctx context.Context, maxAttempts int, delay time.Duration,
fn func() error) error {
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
lastErr = fn()
if lastErr == nil {
return nil
}
if attempt < maxAttempts {
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
}
return fmt.Errorf("all %d attempts failed: %w", maxAttempts, lastErr)
}
// ANTI-PATTERN: hundreds of instances retrying with exactly the same delay
// → thundering herd: all 200 instances retry after exactly 1 second
// → the downstream receives a spike of 200 requests at once, not an even spread
3. Exponential Backoff #
The delay doubles with each failed retry, giving the downstream progressively longer time to recover.
// Delay: 100ms → 200ms → 400ms → 800ms → 1600ms (capped)
func calculateBackoff(attempt int, base, max time.Duration) time.Duration {
delay := base * time.Duration(1<<uint(attempt-1)) // base × 2^(attempt-1)
if delay > max {
delay = max
}
return delay
}
flowchart LR
A["Attempt 1\nFail"] -->|"100ms"| B["Attempt 2\nFail"]
B -->|"200ms"| C["Attempt 3\nFail"]
C -->|"400ms"| D["Attempt 4\nFail"]
D -->|"800ms"| E["Attempt 5\nFail/Success"]
style A fill:#ffcccc
style B fill:#ffcccc
style C fill:#ffcccc
style D fill:#ffccccMuch better than fixed delay, but still a thundering herd if many instances start retrying at the same time because they all use identical delays.
4. Exponential Backoff + Jitter — The Best Practice #
Adding randomization to the delay naturally spreads retry timing. This is the most recommended strategy for almost every production use case.
// Full implementation: exponential backoff + full jitter
func RetryWithBackoff(ctx context.Context, cfg RetryConfig, fn func() error) error {
var lastErr error
for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
lastErr = fn()
if lastErr == nil {
if attempt > 1 {
log.Infof("retry succeeded on attempt %d", attempt)
}
return nil
}
// Fail fast for errors that aren't worth retrying
if !cfg.IsRetryable(lastErr) {
return fmt.Errorf("non-retryable error: %w", lastErr)
}
if attempt == cfg.MaxAttempts {
break // don't wait after the last attempt
}
// Calculate the base delay: min(base × 2^(attempt-1), maxDelay)
baseDelay := cfg.BaseDelay * time.Duration(1<<uint(attempt-1))
if baseDelay > cfg.MaxDelay {
baseDelay = cfg.MaxDelay
}
// Full jitter: random between 0 and baseDelay
// More effective than ±25% at avoiding thundering herds
jitter := time.Duration(rand.Int63n(int64(baseDelay)))
log.Warnf("attempt %d/%d failed: %v — retrying in %v",
attempt, cfg.MaxAttempts, lastErr, jitter)
select {
case <-time.After(jitter):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("exhausted %d attempts: %w", cfg.MaxAttempts, lastErr)
}
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
IsRetryable func(error) bool
}
Comparing delay with and without jitter:
| Attempt | Without Jitter | With Full Jitter |
|---|---|---|
| 1 → 2 | 100ms (all instances) | 0–100ms (random per instance) |
| 2 → 3 | 200ms (all instances) | 0–200ms (random per instance) |
| 3 → 4 | 400ms (all instances) | 0–400ms (random per instance) |
| 4 → 5 | 800ms (all instances) | 0–800ms (random per instance) |
With jitter, 200 instances that fail simultaneously won’t all retry at the same time — they spread naturally across the delay window.
5. Retry with a Deadline / Timeout Budget #
Instead of limiting the attempt count, limit the total time allowed. Very suitable for latency-sensitive systems where user experience depends on fast responses.
// Retry until the deadline — not until max attempts
func RetryUntilDeadline(ctx context.Context, timeout time.Duration,
fn func() error) error {
deadline := time.Now().Add(timeout)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
attempt := 0
baseDelay := 50 * time.Millisecond
for {
attempt++
err := fn()
if err == nil {
return nil
}
remaining := time.Until(deadline)
if remaining <= 0 {
return fmt.Errorf("deadline exceeded after %d attempts: %w", attempt, err)
}
nextDelay := baseDelay * time.Duration(1<<uint(attempt-1))
// Don't retry if the next delay is more than half the remaining time
if nextDelay > remaining/2 {
return fmt.Errorf("insufficient time budget for retry: %w", err)
}
jitter := time.Duration(rand.Int63n(int64(nextDelay)))
select {
case <-time.After(jitter):
case <-ctx.Done():
return ctx.Err()
}
}
}
6. Retry with a Circuit Breaker #
Circuit breakers and retries work together: retry handles transient failures, the circuit breaker prevents futile retries when the downstream is clearly unreachable.
sequenceDiagram
participant C as Caller
participant CB as Circuit Breaker
participant D as Downstream
C->>CB: Attempt 1
CB->>D: Forward (CLOSED)
D-->>CB: Error
CB-->>C: Error → retry
C->>CB: Attempt 2
CB->>D: Forward (CLOSED)
D-->>CB: Error
CB-->>C: Error → retry
C->>CB: Attempt 3
CB->>D: Forward (CLOSED)
D-->>CB: Error
Note over CB: failure rate > threshold\nCircuit OPEN
C->>CB: Attempt 4
CB-->>C: Fail fast — circuit OPEN\nwithout calling downstream
Note over CB: After cooldown: HALF-OPEN
C->>CB: Attempt 5
CB->>D: One probe request
D-->>CB: Success
Note over CB: Circuit CLOSED again
CB-->>C: Success ✓Error Classification: Should and Shouldn’t Be Retried #
This is the most critical decision in designing a retry strategy. Retrying the wrong error can hide bugs or make things worse.
// An explicit error classification function
func isRetryableError(err error) bool {
if err == nil {
return false
}
// Context cancelled or deadline exceeded — don't retry
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
// ✓ SHOULD retry — transient errors
case 429: return true // Too Many Requests — wait and try again
case 500: return true // Internal Server Error — may be transient
case 502: return true // Bad Gateway — upstream is restarting
case 503: return true // Service Unavailable — downstream overloaded
case 504: return true // Gateway Timeout
// ✗ DON'T retry — deterministic errors
case 400: return false // Bad Request — the request is wrong, won't change
case 401: return false // Unauthorized — need to refresh the token first
case 403: return false // Forbidden — no access
case 404: return false // Not Found — the resource doesn't exist
case 409: return false // Conflict — needs special logic, not just retry
case 422: return false // Unprocessable Entity — invalid data
}
}
// Network errors are transient — usually worth retrying
var netErr *net.OpError
if errors.As(err, &netErr) {
return true
}
return false // default: don't retry if unsure
}
Classification summary:
| HTTP Status | Retryable? | Reason |
|---|---|---|
| 400 Bad Request | ❌ No | The request is wrong — it won’t change if repeated |
| 401 Unauthorized | ❌ No | Needs a token refresh first |
| 403 Forbidden | ❌ No | No access, retry won’t help |
| 404 Not Found | ❌ No | The resource doesn’t exist |
| 409 Conflict | ❌ No | Needs resolution logic, not just retry |
| 422 Unprocessable | ❌ No | Invalid data |
| 429 Too Many Requests | ✅ Yes | Rate limited — wait and try again |
| 500 Server Error | ✅ Yes | May be transient |
| 502 Bad Gateway | ✅ Yes | Upstream is restarting |
| 503 Unavailable | ✅ Yes | Downstream temporarily overloaded |
| 504 Gateway Timeout | ✅ Yes | Timeout — may succeed on retry |
| Network timeout | ✅ Yes | Transient connectivity issue |
| context.Canceled | ❌ No | User/caller already cancelled — drop it |
Rule of thumb: if running the same operation with the same input can’t possibly produce a different result, don’t retry. Validation errors and authorization errors are almost never worth retrying without changing something first.
Retry and Idempotency — An Inseparable Relationship #
Retrying without considering idempotency is a latent bug. Every operation being retried must be safe to execute more than once.
// ANTI-PATTERN: retrying a non-idempotent operation
func processPayment(userID string, amount int64) error {
return RetryWithBackoff(ctx, defaultConfig, func() error {
// If the request succeeded but the response timed out before arriving,
// the retry will process a SECOND PAYMENT — double charge!
return paymentGateway.Charge(userID, amount)
})
}
// CORRECT: an idempotency key makes retries always safe
func processPayment(idempotencyKey, userID string, amount int64) error {
return RetryWithBackoff(ctx, defaultConfig, func() error {
return paymentGateway.Charge(ChargeRequest{
IdempotencyKey: idempotencyKey, // ← the gateway dedupes based on this key
UserID: userID,
Amount: amount,
})
// If the previous request already succeeded, the gateway returns the same result
// without reprocessing — retry is 100% safe
})
}
Mandatory rule: before adding retry to an operation, make sure the operation is idempotent — or make it idempotent with an idempotency key. Retry without idempotency on financial or state-mutating operations is a recipe for double processing that can be very costly.
Retries at Various System Layers #
One common mistake is adding retries at every layer without coordination — producing retry amplification that worsens the load on the downstream.
flowchart TD
subgraph Amplification["❌ Retry Amplification — Every Layer Retries on Its Own"]
C1["Client\n3x retry"] --> G1["API Gateway\n3x retry"]
G1 --> S1["Service A\n3x retry"]
S1 --> D1["Downstream\n3×3×3 = 27 calls\nfor 1 user request!"]
end
subgraph Best["✅ Best Practice — Retry at One Right Layer"]
C2["Client\nfail fast"] --> G2["API Gateway\n3x retry ← only here"]
G2 --> S2["Service A\nfail fast"]
S2 --> D2["Downstream\nmax 3 calls"]
endPer-layer retry guidance:
| Layer | Good Retry For | Notes |
|---|---|---|
| HTTP Client | Timeouts, 5xx from upstream | Idempotency key mandatory |
| Message Consumer | Transient processing errors | Combine with a DLQ |
| Background Worker | Flaky external API calls | Combine with job state persistence |
| Database Client | Deadlocks, transient connection errors | Already built into many drivers |
| gRPC Client | UNAVAILABLE, DEADLINE_EXCEEDED statuses | Use the retry policy in service config |
Dart/Flutter Implementation (Dio Interceptor) #
// Retry transport for http.Client — applies to all HTTP requests
type RetryTransport struct {
base http.RoundTripper
maxRetries int
baseDelay time.Duration
}
func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
retryCount := 0
for {
resp, err := t.base.RoundTrip(req)
if !isRetryable(err, resp) || retryCount >= t.maxRetries {
return resp, err
}
retryCount++
// Exponential backoff with full jitter
maxDelayMs := int64(t.baseDelay.Milliseconds()) * int64(1<<uint(retryCount))
jitterMs := rand.Int63n(maxDelayMs)
log.Printf("[RETRY] attempt %d/%d in %dms for %s",
retryCount, t.maxRetries, jitterMs, req.URL.Path)
time.Sleep(time.Duration(jitterMs) * time.Millisecond)
}
}
func isRetryable(err error, resp *http.Response) bool {
// Transient network errors
if err != nil {
return true
}
// Retryable HTTP statuses
return resp != nil && (resp.StatusCode == 429 || resp.StatusCode == 500 ||
resp.StatusCode == 502 || resp.StatusCode == 503 || resp.StatusCode == 504)
}
// Setup in main
func setupClient() *http.Client {
return &http.Client{
Transport: &RetryTransport{maxRetries: 3, baseDelay: 200 * time.Millisecond},
}
}
Observability for Retry #
An unobserved retry is a retry that can’t be tuned. Without metrics, you don’t know whether your retry configuration is optimal or just hiding a deeper problem.
// Mandatory metrics for every retry mechanism
func RetryWithMetrics(ctx context.Context, operationName string,
cfg RetryConfig, fn func() error) error {
startTime := time.Now()
totalAttempts := 0
err := RetryWithBackoff(ctx, cfg, func() error {
totalAttempts++
attemptErr := fn()
if attemptErr != nil && totalAttempts > 1 {
metrics.Counter("retry.attempt",
"operation", operationName,
"attempt", strconv.Itoa(totalAttempts),
).Inc()
}
return attemptErr
})
duration := time.Since(startTime)
if err != nil {
// All retries exhausted and still failing
metrics.Counter("retry.exhausted", "operation", operationName).Inc()
metrics.Histogram("retry.duration_on_failure",
"operation", operationName,
).Record(duration.Seconds())
} else if totalAttempts > 1 {
// Recovered after retries — track how many attempts
metrics.Counter("retry.recovered",
"operation", operationName,
"attempts", strconv.Itoa(totalAttempts),
).Inc()
}
return err
}
Metrics that must be monitored:
| Metric | Meaning | Alert If |
|---|---|---|
retry.attempt count | How many retries happened | Keeps rising → downstream is struggling |
retry.exhausted count | Retries exhausted, still failing | > 0 → needs investigation |
retry.recovered count | Succeeded after retries | Needed to calculate the recovery rate |
retry.duration_on_failure | Total time including all retries | Too high → lower max attempts |
Anti-Patterns to Avoid #
// ✗ Infinite retry — system stuck, resource leak
for {
err := callService()
if err == nil { break }
time.Sleep(100 * time.Millisecond)
}
// ✓ Always have MaxAttempts or a total timeout budget
// ✗ Retrying every error without classification
RetryWithBackoff(ctx, cfg, func() error {
return validateUserInput(req) // a 400 won't change if retried!
})
// ✓ Define an explicit IsRetryable
// ✗ Layered retries without coordination — 3×3×3 = 27 calls amplification
// ✓ Designate one responsible layer, others fail fast
// ✗ No delay — immediate retry for network calls
for attempt := 0; attempt < 5; attempt++ {
callExternalService() // floods the downstream
}
// ✓ Exponential backoff + jitter
// ✗ Retrying non-idempotent operations without an idempotency key
RetryWithBackoff(ctx, cfg, func() error {
return chargeCard(userID, amount) // could double charge!
})
// ✓ Ensure idempotency before adding retry
// ✗ Hiding all errors after retries are exhausted
if err := RetryWithBackoff(...); err != nil {
log.Error(err)
return nil // ← the caller doesn't know there's a problem!
}
// ✓ Propagate the error, let the caller decide how to handle it
// ✗ Retrying after context cancellation
for attempt := 1; attempt <= maxAttempts; attempt++ {
fn()
time.Sleep(delay) // doesn't check ctx.Done()!
}
// ✓ Always check ctx.Done() in a select before waiting for a delay
Retry Strategy Implementation Checklist #
CONFIGURATION:
□ MaxAttempts or a total timeout budget set (not infinite)
□ BaseDelay, MaxDelay adjusted to the downstream's SLA
□ Full jitter enabled to prevent thundering herds
ERROR CLASSIFICATION:
□ IsRetryable function explicitly defined
□ 4xx errors (except 429) excluded from retries
□ context.Canceled and context.DeadlineExceeded excluded from retries
IDEMPOTENCY:
□ All retried operations verified as idempotent
□ Idempotency keys used for non-idempotent operations
LAYER DESIGN:
□ No stacked retries across multiple layers for the same operation
□ The layer responsible for retries is designated
OBSERVABILITY:
□ retry.attempt, retry.exhausted, retry.recovered tracked as metrics
□ Alerts attached if retry.exhausted exceeds the threshold
□ Logs include the attempt number, delay, and error message
TESTING:
□ Happy-path tests: succeeds on the first attempt
□ Retry-path tests: fails N times, succeeds on the last attempt
□ Exhausted tests: all attempts fail, DLQ or final error
□ Non-retryable tests: fails fast immediately without retry
Summary #
- A Retry Strategy is a systematic approach — not a plain loop; it has clear rules about when, how many times, how long to wait, and for which errors retries happen.
- Six retry types: immediate (avoid for network), fixed delay (thundering herd risk), exponential backoff (better), exponential backoff + jitter (best practice), deadline-based (latency-sensitive), combined with a circuit breaker (prevents futile retries).
- Exponential backoff + full jitter is the best default — it gives the downstream time to recover while spreading retry timing to avoid thundering herds.
- Error classification is critical: 4xx (except 429) is almost never worth retrying; 5xx and timeouts usually are; context.Canceled must not be retried.
- Idempotency is an absolute prerequisite — before adding retry to any operation, make sure it’s safe to run more than once; use an idempotency key if needed.
- Retry amplification happens when every layer retries independently — 3×3×3 = 27 calls for 1 user request; designate one responsible layer.
- Context cancellation must be respected — always check
ctx.Done()between retries; don’t continue after the context is cancelled.- Observability is mandatory — track retry counts, recovery rates, and exhausted counts; without metrics you can’t know if the configuration is optimal.
- Infinite retry is an anti-pattern — always have max attempts or a total timeout budget to prevent resource leaks and stuck systems.
← Previous: Aspect Oriented Programming Next: Backoff Strategy →