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 --> B

Why 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:#ffcccc

Much 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:

AttemptWithout JitterWith Full Jitter
1 → 2100ms (all instances)0–100ms (random per instance)
2 → 3200ms (all instances)0–200ms (random per instance)
3 → 4400ms (all instances)0–400ms (random per instance)
4 → 5800ms (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 StatusRetryable?Reason
400 Bad Request❌ NoThe request is wrong — it won’t change if repeated
401 Unauthorized❌ NoNeeds a token refresh first
403 Forbidden❌ NoNo access, retry won’t help
404 Not Found❌ NoThe resource doesn’t exist
409 Conflict❌ NoNeeds resolution logic, not just retry
422 Unprocessable❌ NoInvalid data
429 Too Many Requests✅ YesRate limited — wait and try again
500 Server Error✅ YesMay be transient
502 Bad Gateway✅ YesUpstream is restarting
503 Unavailable✅ YesDownstream temporarily overloaded
504 Gateway Timeout✅ YesTimeout — may succeed on retry
Network timeout✅ YesTransient connectivity issue
context.Canceled❌ NoUser/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"]
    end

Per-layer retry guidance:

LayerGood Retry ForNotes
HTTP ClientTimeouts, 5xx from upstreamIdempotency key mandatory
Message ConsumerTransient processing errorsCombine with a DLQ
Background WorkerFlaky external API callsCombine with job state persistence
Database ClientDeadlocks, transient connection errorsAlready built into many drivers
gRPC ClientUNAVAILABLE, DEADLINE_EXCEEDED statusesUse 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:

MetricMeaningAlert If
retry.attempt countHow many retries happenedKeeps rising → downstream is struggling
retry.exhausted countRetries exhausted, still failing> 0 → needs investigation
retry.recovered countSucceeded after retriesNeeded to calculate the recovery rate
retry.duration_on_failureTotal time including all retriesToo 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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact