17.8. Handling Failed Jobs
Table of Contents
Why Failed Jobs Matter
Background jobs are supposed to run without user interaction, often after a request is done. When they fail, the user might not see an error directly, but important work is not done. For example:
- Email not sent after registration
- Payment not confirmed
- Report not generated
If you do not handle failed jobs, you will get:
- Lost data or inconsistent state
- Angry users and support tickets
- Hard to debug production incidents
Good failure handling makes your system more reliable and easier to operate.
Key rule:
Every background processing system must have a clear strategy for detecting, recording, retrying, and sometimes manually fixing failed jobs.
Types of Failures in Background Jobs
Temporary vs Permanent Failures
You should separate failures into two categories:
| Type | Description | Examples | Typical Action |
|---|---|---|---|
| Temporary | Likely to succeed if tried again | Network glitches, rate limits, timeouts | Retry later |
| Permanent | Will not succeed without a change | Invalid email, deleted user, bad config | Do not retry blindly |
Examples:
- Temporary:
- SMTP server busy, returns a 4xx error
- Database connection timeout
- Third party API rate limit exceeded
- Permanent:
- Email address has invalid format
- Order id in job does not exist in DB
- Code has a bug that raises the same exception every time
If you treat permanent failures like temporary ones, you might create an infinite retry loop and overload your system.
Synchronous vs Asynchronous Failures
Failures can happen:
- Before the job is sent to the queue (in your API handler)
- During enqueueing (cannot push to message broker)
- In the worker, while executing the job
- After the job finishes, when saving results
You mainly focus on failures in the worker, but do not forget the others. For example, if enqueueing fails, you may need to:
- Return an error to the client
- Or store the task in a fallback storage to enqueue later
Detecting and Recording Failed Jobs
Logging Failures
Every failed job must be logged with enough context:
- Job type (e.g.
send_welcome_email) - Job parameters (sanitized, no secrets)
- Time of failure
- Exception type and message
- Stack trace
Example in Python with Celery-style code:
import logging
logger = logging.getLogger(__name__)
def send_welcome_email(user_id: int):
try:
user = get_user_from_db(user_id)
email_service.send_welcome_email(user.email)
except Exception as exc:
logger.exception("Failed to send welcome email", extra={"user_id": user_id})
raise
logger.exception automatically adds the stack trace.
Storing Failed Job Metadata
Log files are not enough. You often want a structured record, for example in:
- A database table
failed_jobs - Redis hashes
- A dead-letter queue
Example of a failed_jobs table:
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| job_type | text | Name of the job function |
| payload | jsonb | Job arguments |
| error_message | text | Short error description |
| error_type | text | Exception class name |
| stack_trace | text | Optional, for debugging |
| retries | int | How many times tried |
| status | text | e.g. failed, will_retry |
| created_at | timestamp | When first failed |
| last_attempt_at | timestamp | When last retried |
This lets you:
- Search and filter failed jobs
- Build admin tools to inspect and re-run jobs
- Collect metrics on failure rates
Dead-Letter Queues (DLQ)
A dead-letter queue is a special queue where messages (jobs) go when they cannot be processed successfully.
Basic idea:
- Worker receives job from main queue.
- Worker tries to process it up to N times.
- If it still fails, job is moved to the DLQ instead of being lost.
Benefits:
- Problematic jobs do not block normal traffic.
- You can inspect DLQ messages separately.
- You can replay DLQ messages after fixing bugs.
Many message brokers support DLQ behavior, for example:
- RabbitMQ has dead-letter exchanges
- AWS SQS has dead-letter queues
Retry Strategies
Simple Retries
The most basic approach is to retry a job a fixed number of times.
Example logic:
- Max 3 attempts
- Wait 10 seconds between attempts
Pseudo code:
MAX_RETRIES = 3
DELAY_SECONDS = 10
def process_job(job):
for attempt in range(1, MAX_RETRIES + 1):
try:
do_work(job)
return # success
except Exception as exc:
if attempt == MAX_RETRIES:
move_to_dead_letter(job, exc)
else:
sleep(DELAY_SECONDS)Problems with fixed delay:
- Can cause spikes if many jobs fail at once
- Not ideal when a service is down for longer time
Exponential Backoff
Exponential backoff means that waiting time increases with each retry. This is common in resilient systems.
Formula example:
- Wait time for attempt $n$:
$$ delay_n = base\_delay \times 2^{(n - 1)} $$
If base_delay = 5 seconds:
| Attempt | Delay (seconds) |
|---|---|
| 1 | 5 |
| 2 | 10 |
| 3 | 20 |
| 4 | 40 |
Important rule:
Use exponential backoff for retries of external calls like APIs or email servers to avoid overloading them and to give them time to recover.
Example:
import time
MAX_RETRIES = 5
BASE_DELAY = 5 # seconds
def send_notification_with_retry(notification):
for attempt in range(1, MAX_RETRIES + 1):
try:
send_notification(notification)
return
except TemporaryError as exc:
if attempt == MAX_RETRIES:
raise
delay = BASE_DELAY * (2 ** (attempt - 1))
time.sleep(delay)Adding Jitter
If many workers retry at the same time with the same pattern, you can create "retry storms". To avoid this, add randomness (jitter) to the delay.
Example:
import random
import time
def compute_delay_with_jitter(base_delay: int, attempt: int) -> float:
delay = base_delay * (2 ** (attempt - 1))
jitter = random.uniform(0, delay * 0.1) # up to 10% extra
return delay + jitterThis spreads retries over time and smooths the load.
Retry Only Safe Operations
Some jobs are safe to retry, some are not.
Safe (idempotent or easy to detect duplicates):
- Sending email if you can tolerate duplicates
- Generating reports that overwrite previous ones
- Calling APIs that are idempotent (like PUT requests with same body)
Dangerous:
- Charging a credit card without idempotency key
- Creating a record without a unique constraint
For non-idempotent jobs, use:
- Idempotency keys
- Database constraints to detect duplicates
- Careful business rules
Example: payment job that uses an idempotency key and is safe to retry.
def charge_customer(order_id: int, idempotency_key: str):
if payment_exists(idempotency_key):
return # already processed
result = payment_gateway.charge(order_id, idempotency_key=idempotency_key)
save_payment(result, idempotency_key)Distinguishing Retryable vs Non-Retryable Errors
Classifying Exceptions
Define which errors can be retried and which cannot.
Example categories:
- Retryable: network errors, timeouts, rate limit responses
- Non-retryable: validation errors, business rule violations, "not found" cases
In Python you can create custom exception classes:
class RetryableError(Exception):
pass
class NonRetryableError(Exception):
passUse them in your job:
def send_invoice(order_id: int):
order = get_order(order_id)
if not order:
raise NonRetryableError(f"Order {order_id} not found")
try:
pdf = generate_invoice_pdf(order)
except PdfEngineTimeout as exc:
raise RetryableError("PDF service timeout") from exc
email_service.send_attachment(order.customer_email, pdf)Your worker can then decide:
try:
send_invoice(order_id)
except RetryableError as exc:
schedule_retry(order_id, error=exc)
except NonRetryableError as exc:
record_permanent_failure(order_id, error=exc)Blacklist vs Whitelist Approach
Two strategies to decide what to retry:
- Blacklist: retry everything except a few known non-retryable errors
- Whitelist: retry only explicitly known retryable errors
For beginners, whitelist is often safer, for example:
- Only retry on network-related exceptions
- Everything else is treated as permanent failure
Limiting Retries and Preventing Infinite Loops
Maximum Attempts
Each job should have a clear limit:
- Maximum number of attempts
- Or maximum retry time window (for example do not retry after 24 hours)
Store the attempt count as:
- A field in the message (for example
x-retry-count) - A column in a database table
Example:
MAX_ATTEMPTS = 5
def should_retry(job):
return job.attempts < MAX_ATTEMPTSIf you skip this, you may create:
- Infinite retry loops
- High CPU and network usage
- Large bills for external services
Circuit Breaker Concepts
A circuit breaker stops making calls to a failing dependency for a while.
In the context of jobs, if you see many failures for a specific dependency:
- You might temporarily stop processing related jobs
- Or immediately send them to DLQ without retry
This protects:
- The failing service from more pressure
- Your system from wasting resources
Full circuit breaker patterns are an advanced topic, but as a beginner you should at least:
- Monitor failure rates
- Alert when a failure threshold is exceeded
- Consider pausing job processing manually in emergencies
Manual Intervention and Admin Tools
Inspecting Failed Jobs
You need tools to view failed jobs, for example:
- A simple admin web page
- A CLI script
- A dashboard from the job framework
Useful information to show:
- Job type and payload (with sensitive data hidden)
- Error message and stack trace
- Number of attempts
- When it failed
Example of a simple view in a web admin:
| ID | Job Type | Error Message | Attempts | Last Attempt At |
|---|---|---|---|---|
| 1 | send_welcome | SMTP timeout | 3 | 2026-08-28 13:05:21 |
| 2 | generate_report | User 42 not found | 1 | 2026-08-28 13:07:10 |
Requeueing Jobs
After you fix a bug or a configuration, you may want to re-run some failed jobs.
Possible actions from your admin tool:
- "Retry this single job"
- "Retry all failed jobs of type X during last day"
When requeueing:
- Reset or increase the attempt counter carefully
- Make sure the job is idempotent or safe to run again
Pseudo code for requeueing a job:
def requeue_failed_job(failed_job_id: int):
failed_job = db.get_failed_job(failed_job_id)
enqueue_job(
type=failed_job.job_type,
payload=failed_job.payload,
attempts=failed_job.retries + 1
)
db.mark_failed_job_as_requeued(failed_job_id)Marking Jobs as Resolved
Not every failed job should be re-run. Sometimes you decide:
- The data is no longer relevant
- Fixing it is too expensive
- It represents user error
In such cases, mark the job as "resolved" without retry.
This prevents your failure lists from growing forever and lets you track what is still pending.
Idempotency and Safe Re-Runs
What Is Idempotency in Jobs?
A job is idempotent if running it multiple times produces the same result as running it once.
Examples:
- "Rebuild search index" can be idempotent if it always writes the latest state
- "Send daily summary email" could be idempotent if you check if already sent for that day
Idempotency is very helpful for failed jobs because you can:
- Retry aggressively
- Manually re-run jobs without fear of double effects
Techniques for Idempotent Jobs
Some practical methods:
- Use unique constraints in DB
Example: a payments table with unique constraint on order_id:
ALTER TABLE payments
ADD CONSTRAINT unique_order_payment UNIQUE (order_id);If a job tries to insert a second payment for the same order, it fails, and you can catch that as "already processed".
- Use idempotency keys
You store a unique key for each logical operation. If you see the same key again, you return the previous result instead of doing the work again.
- Check state before acting
For example:
def mark_order_as_shipped(order_id: int):
order = get_order(order_id)
if order.status == "shipped":
return # already shipped
update_order_status(order_id, "shipped")Running this twice does not break anything.
Observability for Failed Jobs
Metrics
Collect metrics about your job system, such as:
- Number of jobs processed per minute
- Number of failed jobs per job type
- Retry counts
- Time spent per job
You can then:
- Set alerts when failures spike
- See trends when you deploy new code
Example metric names:
| Metric Name | Meaning |
|---|---|
jobs_processed_total | Total number of processed jobs |
jobs_failed_total | Total number of failed jobs |
jobs_retry_total | Total number of retries |
job_duration_seconds | Execution time per job |
Traces and Correlation IDs
Correlate jobs with user actions using:
- Correlation IDs passed from HTTP request to job payload
- Logging that includes this ID
This makes debugging much easier. For example:
- User request to
/checkoutgetsrequest_id=abc123 - You enqueue job with payload
{"order_id": 10, "request_id": "abc123"} - Logs from job also include
request_id=abc123
Then you can trace the full flow through system logs.
Putting It All Together: Example Flow
Consider a job send_order_confirmation_email(order_id):
- API receives a request to place an order.
- After saving the order, API enqueues a job with
order_idandrequest_id. - Worker picks the job and runs it.
Possible outcomes:
- Success
- Email is sent.
- Job is marked as completed.
- Temporary failure (SMTP timeout)
- Worker catches
RetryableError. - Job is rescheduled with exponential backoff.
- Retry count is increased.
- After max retries
- Job is moved to DLQ or
failed_jobstable. - Metrics and error logs are recorded.
- Alert may be triggered if many such failures occur.
- Developer fixes bug or SMTP config
- Uses admin tool to inspect failed email jobs.
- Requeues selected jobs.
- Jobs now succeed, and records are marked as resolved.
This flow avoids losing important work, keeps the system stable, and gives you tools to recover from problems.
Views: 17
KAHIBARO