17.7. Retry Strategies
Table of Contents
Why Retry Strategies Matter
In background processing, things often fail temporarily. A database might be momentarily overloaded, an email provider might return a 500 error, or a network link might drop for a second.
If you treat every error as permanent, you will lose work. If you treat every error as temporary and retry blindly, you can overload your own systems or external services.
A retry strategy is a set of rules that decide:
- When to retry a failed job.
- How many times to retry.
- How long to wait between retries.
- When to give up and mark the job as permanently failed.
A good strategy maximizes successful completion of tasks while avoiding overload and infinite loops.
A background system without a well‑defined retry strategy is a reliability risk. It can either lose important work or create retry storms that overload your own services and external APIs.
What Can Be Safely Retried?
Not every failure should be retried. Some are permanent and will never succeed, others are transient and might succeed later.
Transient vs Permanent Errors
Examples of transient errors (good candidates for retry):
- Temporary network failures (connection timeouts, DNS timeouts).
- HTTP 5xx responses from external services.
- Database “too many connections” or “deadlock detected” errors.
- Message broker temporarily unavailable.
Examples of permanent errors (usually do not retry):
- Invalid input that breaks validation.
- HTTP 4xx client errors like 400, 401, 403, 404, 422.
- Business rule violations, for example “user does not have enough balance.”
- Resource not found that will not appear later, for example “product id 123 does not exist” in a stable catalog.
In code, you usually:
- Classify exceptions into “retryable” and “non‑retryable.”
- Stop the retry loop immediately on non‑retryable errors.
- Only do backoff for retryable ones.
Example in Python‑style pseudocode:
class PermanentError(Exception):
pass
class TransientError(Exception):
pass
def process_job(job):
if not job.data_is_valid():
raise PermanentError("Invalid job data")
try:
external_service_call(job)
except TimeoutError as exc:
raise TransientError("Temporary network issue") from exc
The worker’s retry logic will treat PermanentError and TransientError very differently.
Basic Retry Parameters
Every retry strategy is defined by a few key parameters.
Maximum Number of Attempts
You almost always define a maximum number of attempts, including the first try.
For example:
max_attempts = 1means no retry, only one attempt.max_attempts = 3means 1 initial attempt plus up to 2 retries.max_attempts = ∞is usually a bad idea.
Never allow unlimited retries for a job. Always set a maximum number of attempts or a maximum total time window for retries.
Delay Between Attempts
You also define how long to wait before each retry:
- No delay, retry immediately.
- Fixed delay, for example always wait 10 seconds.
- Backoff delay, grow the wait time for each attempt.
Even a small delay reduces pressure on failing systems.
Fixed Delay Strategy
The simplest strategy is fixed delay.
- You set
max_attemptsand adelayin seconds. - After each failure, you wait exactly
delayseconds before trying again.
Example:
max_attempts = 5delay = 10seconds
Timeline:
Attempt | Result | Next retry in
--------|---------|---------------
1 | Fails | 10 s
2 | Fails | 10 s
3 | Fails | 10 s
4 | Fails | 10 s
5 | Fails | Stop
Pseudocode:
max_attempts = 5
delay = 10 # seconds
for attempt in range(1, max_attempts + 1):
try:
perform_task()
break
except TransientError as exc:
if attempt == max_attempts:
log_failure(exc)
break
sleep(delay)When fixed delay is useful:
- The remote system is rate limited but you know a safe delay.
- You want very simple, predictable behavior.
Downside:
- It does not reduce load quickly when many jobs fail together.
- It may still hammer a sick system too frequently.
Exponential Backoff
Exponential backoff increases the delay after each failure. Each retry waits longer than the previous one, typically doubling the wait time.
Common formula:
$$
\text{delay}_n = \text{base\_delay} \times 2^{(n - 1)}
$$
Where:
- $\text{delay}_n$ is the delay before the $n$‑th retry (not including the first attempt).
- $\text{base\_delay}$ is the initial delay in seconds.
Example with base_delay = 2 seconds, max_attempts = 5:
Attempt | Delay before this attempt | Comment
--------|---------------------------|--------
1 | 0 s | First attempt
2 | $2 \times 2^{0} = 2$ s | 2 seconds
3 | $2 \times 2^{1} = 4$ s | 4 seconds
4 | $2 \times 2^{2} = 8$ s | 8 seconds
5 | $2 \times 2^{3} = 16$ s | 16 seconds
Total waited time is $2 + 4 + 8 + 16 = 30$ seconds.
Pseudocode:
max_attempts = 5
base_delay = 2 # seconds
attempt = 1
while attempt <= max_attempts:
try:
perform_task()
break
except TransientError as exc:
if attempt == max_attempts:
log_failure(exc)
break
delay = base_delay * (2 ** (attempt - 1))
sleep(delay)
attempt += 1Benefits:
- Fast retries at first, since the issue may resolve quickly.
- Longer waits later, which reduces pressure on the failing system.
Exponential Backoff With Maximum Delay
Pure exponential backoff can grow too large. You usually add a maximum delay cap.
Always set a maximum backoff delay to avoid extremely long waits. For example, cap delays at 1 minute or 5 minutes.
Formula with cap:
$$
\text{delay}_n = \min\left(\text{base\_delay} \times 2^{(n - 1)}, \text{max\_delay}\right)
$$
Example:
base_delay = 2secondsmax_delay = 30seconds
Attempt | Raw delay | Capped delay
--------|-----------|-------------
1 | 0 | 0
2 | 2 | 2
3 | 4 | 4
4 | 8 | 8
5 | 16 | 16
6 | 32 | 30 (capped)
7 | 64 | 30 (capped)
Jitter: Avoiding Thundering Herds
If all workers use the same retry schedule, they may all retry at exactly the same times. This can cause a thundering herd effect: many clients hit a recovering service at once and keep it down.
Jitter is randomization added to the delay. Instead of sleeping exactly 10 seconds, you sleep a random duration around 10 seconds.
Common patterns:
- Full jitter: random delay between 0 and the computed backoff.
- Equal jitter: average between base and full jitter.
- Decorrelated jitter: uses previous delay in the formula.
A simple full jitter version:
$$
\text{delay}_n = \text{random}(0, \text{base\_delay} \times 2^{(n - 1)})
$$
Example with full jitter:
- Backoff value without jitter for attempt 3 is 4 seconds.
- With jitter, you pick a random value in
[0, 4], for example2.7seconds.
Pseudocode with full jitter:
import random
import time
max_attempts = 5
base_delay = 2 # seconds
max_delay = 30 # seconds
for attempt in range(1, max_attempts + 1):
try:
perform_task()
break
except TransientError as exc:
if attempt == max_attempts:
log_failure(exc)
break
# Exponential backoff with cap
backoff = base_delay * (2 ** (attempt - 1))
backoff = min(backoff, max_delay)
# Apply jitter
delay = random.uniform(0, backoff)
time.sleep(delay)Large systems often use some form of exponential backoff with jitter for external calls.
Time‑Based vs Attempt‑Based Limits
You can limit retries by:
- Number of attempts: for example
max_attempts = 5. - Total time: for example stop retrying after 15 minutes, regardless of attempts.
- Or a combination of both.
Example combined rule:
- Up to 10 attempts.
- Stop if the job is older than 1 hour.
Pseudocode:
MAX_ATTEMPTS = 10
MAX_AGE_SECONDS = 3600
def should_retry(job, attempt, error):
if attempt >= MAX_ATTEMPTS:
return False
if time.time() - job.created_at_timestamp > MAX_AGE_SECONDS:
return False
if isinstance(error, PermanentError):
return False
return TrueThis prevents stuck jobs that keep retrying for days.
Idempotency and Retries
When you retry a job, the code can run multiple times for the same logical operation. You must make sure retries do not create unwanted side effects.
An operation is idempotent if performing it multiple times has the same effect as performing it once.
Examples:
- Setting a user’s email to
"alice@example.com"is idempotent. Doing it twice has the same final state. - Charging a credit card
$10is not idempotent, because doing it twice charges$20.
For background jobs:
- Safe to retry: sending a notification that is designed to deduplicate, updating a record to a known value, upserting data.
- Dangerous to retry: blindly charging payments, creating duplicate orders, double sending irreversible messages.
To make non‑idempotent operations safer, you can:
- Use idempotency keys.
- Check whether an operation was already applied before applying it again.
- Design your data model to support upserts (
INSERT OR UPDATE).
Example: avoid double charging with an idempotency key:
def charge_user(user_id, amount, operation_id):
if payment_already_processed(operation_id):
return # Do nothing, this retry is safe
# perform actual charge
result = payment_gateway.charge(user_id, amount)
# store operation_id as processed
mark_payment_processed(operation_id, result)
If the job retries with the same operation_id, it will not charge again.
Never implement retries for non‑idempotent operations without an explicit strategy to avoid duplicate side effects.
Categorizing Failures for Retry
In a real system you often decide whether to retry based on error types, error codes, or HTTP status codes.
HTTP Based Decisions
Typical rules for calling external HTTP APIs:
Status | Retry? | Reason
-------|--------|-------
2xx | No | Request succeeded
4xx | No | Client or input error, fix the request instead
408 | Yes | Request timeout, usually transient
429 | Yes | Too many requests, back off and retry later
5xx | Yes | Server errors are usually transient
599 | Yes | Network timeout or proxy error
Example:
def should_retry_http(status_code: int) -> bool:
if 200 <= status_code < 300:
return False
if status_code == 408:
return True
if status_code == 429:
return True
if 500 <= status_code < 600:
return True
return FalseDatabase Based Decisions
For databases, you can retry some errors:
Retryable:
- Deadlocks.
- Connection timeouts.
- “Too many connections” (with backoff).
Not retryable:
- Unique constraint violations (duplicate key).
- Foreign key violations.
- Syntax errors.
You usually map database error codes to retryable / non‑retryable classifications.
Retry Strategies in Job Queues
Most background job systems and message queues have built‑in support for retries. You configure the strategy instead of writing all logic yourself.
Example: Simple Retry in a Worker
A generic worker pattern:
def worker_loop():
while True:
job = queue.pop()
attempt = job.attempts + 1
try:
process_job(job)
mark_done(job)
except TransientError as exc:
reschedule(job, attempt, exc)
except PermanentError as exc:
mark_failed(job, exc)
Where reschedule uses your strategy:
def reschedule(job, attempt, error):
delay = compute_delay(attempt)
if not should_retry(job, attempt, error):
mark_failed(job, error)
return
job.attempts = attempt
job.next_run_at = now() + delay
queue.push(job)Example Settings for a Real System
Here is a realistic configuration you might choose for sending emails:
Parameter | Value
------------------|-------
Max attempts | 8
Base delay | 5 seconds
Max delay | 15 minutes
Backoff | Exponential with jitter
Total time limit | 24 hours
Retry only on | Network errors and provider 5xx codes
For payment jobs you might choose something stricter:
Parameter | Value
------------------|-------
Max attempts | 4
Base delay | 10 seconds
Max delay | 5 minutes
Backoff | Exponential with jitter
Total time limit | 1 hour
Retry only on | Explicitly marked retryable errors from gateway
Idempotency key | Required per operation
Guardrails and Monitoring
Retries can hide problems. If every third attempt fails but retries succeed, users might not notice, but your system is under stress.
You need metrics and alerts:
- Count of retries per job type.
- Number of jobs that hit the maximum attempts.
- Average number of attempts before success.
- Delays between job creation and completion.
Example alerts:
- “More than 5% of jobs for
send_emailare reaching maximum retries.” - “Average attempts for
charge_paymentabove 1.5 for the last 10 minutes.”
Also consider circuit breakers (covered elsewhere in the course). If too many retries fail, you may temporarily stop sending new requests to a service, instead of endlessly retrying.
Practical Guidelines
To design a retry strategy for a new background task:
- Decide if the task is safe to retry.
- If not idempotent, design idempotency first.
- Identify retryable errors.
- Map exceptions and status codes to retryable / non‑retryable.
- Choose limits.
max_attemptsand possibly a total time limit.- Pick a delay pattern.
- For most cases, use exponential backoff with jitter and a max delay.
- Set clear failure behavior.
- Where do jobs go after final failure, for example a dead‑letter queue?
- Monitor and adjust.
- Start conservative, then tune based on real failure patterns.
If you apply these rules consistently, your background processing system will be both robust and gentle on the systems it depends on.
Views: 18
KAHIBARO