An API request error handling and checkpoint system combines retry logic, structured error classification, and durable state snapshots so long-running or multi-step API workflows can resume instead of restarting. This matters most for pipelines that call external APIs (payment processors, LLM providers, data enrichment services) where a single failed call shouldn't force you to redo the entire job.
This guide walks through the architecture piece by piece: how to classify errors, when to retry versus fail fast, how to design a checkpoint schema, and where to store checkpoint state on AWS. We'll build a concrete example — a batch job that calls a third-party API for 10,000 records — and show the code and infrastructure choices that make it resilient. Whether you're building this by hand or evaluating an API request error handling and checkpoint system maker (a low-code/generator tool that scaffolds this pattern), the underlying design decisions are the same.
What Problem Are We Actually Solving?
A checkpoint-and-retry system exists to make multi-step or long-running API workflows idempotent and resumable, so a crash, timeout, or rate-limit event costs you seconds of rework instead of hours.
Without it, a common failure mode looks like this: you're processing 10,000 records through a third-party enrichment API. Record 8,743 gets a 429 Too Many Requests. Your script has no retry logic, so it throws, the process dies, and you have no record of what succeeded. You either reprocess everything (wasting API quota and money) or write ad-hoc tracking scripts under deadline pressure. Multiply this by nightly batch jobs, webhook processors, and LLM-based agents, and it becomes a recurring operational tax.
The fix has two independent but complementary parts:
- Error handling — deciding, per error type, whether to retry, back off, skip, or abort.
- Checkpointing — persisting progress markers so work can resume from the last known-good point rather than from zero.
Classifying API Errors Correctly
Not all errors deserve the same response — treating a 400 Bad Request the same as a 503 Service Unavailable is the single most common design mistake in retry logic.
Split errors into four buckets:
- Transient/retryable —
429,502,503,504, connection resets, DNS blips. Retry with backoff. - Rate-limit specific —
429with aRetry-Afterheader. Honor the header value rather than guessing. - Client/permanent —
400,401,403,404,422. Retrying won't help; log and route to a dead-letter queue or manual review. - Ambiguous/timeout — request timed out but you don't know if the server processed it. This is the dangerous case for non-idempotent operations (e.g., "charge card," "[[create](https://www.draft1.ai/blog/how-to-create-a-pharmacy-website-data-flow-diagram-step-by-step-guide)](https://www.draft1.ai/blog/how-to-create-a-ci-cd-pipeline-and-workflow-step-by-step-guide) order") — retrying blindly can cause duplicates.
For the ambiguous case, use idempotency keys. Most modern APIs (Stripe, many payment gateways, and increasingly REST APIs following the IETF Idempotency-Key draft header convention) accept a client-generated key so a retried request with the same key returns the original result instead of executing twice.
import requests
import uuid
idempotency_key = str(uuid.uuid4()) # generate once, reuse on every retry of this logical operation
response = requests.post(
"https://api.example.com/v1/orders",
json=payload,
headers={"Idempotency-Key": idempotency_key},
timeout=10
)
Designing the Retry Strategy: Backoff, Jitter, and Limits
A good retry strategy uses exponential backoff with jitter and a hard retry ceiling, so retries relieve pressure on a struggling API instead of adding to it.
Plain exponential backoff (1s, 2s, 4s, 8s...) causes "thundering herd" problems when many clients retry in lockstep. AWS's own guidance (documented in the AWS Architecture Blog's "Exponential Backoff and Jitter" post) recommends adding randomized jitter to spread retries out.
import time, random
def retry_with_backoff(fn, max_retries=5, base_delay=1, max_delay=30):
for attempt in range(max_retries):
try:
return fn()
except RetryableError as e:
if attempt == max_retries - 1:
raise
delay = min(max_delay, base_delay * (2 ** attempt))
sleep_time = random.uniform(0, delay) # full jitter
time.sleep(sleep_time)
Set concrete limits: a max of 3-5 retries for interactive requests, a longer ceiling (with a circuit breaker) for background batch jobs. If you're calling AWS services directly, note that the AWS SDKs already implement retry with jitter internally (the default in boto3 is the standard retry mode with exponential backoff, configurable via retries.max_attempts and retries.mode in the config), so don't stack a second uncoordinated retry layer on top without care — you can end up with retry storms of retries.
Also implement a circuit breaker: after N consecutive failures to the same endpoint, stop calling it for a cooldown window rather than retrying every single item into a dead API. Libraries like pybreaker (Python) or resilience4j (Java) implement this pattern; AWS App Mesh and API Gateway also expose circuit-breaker-adjacent controls at the infrastructure level.
Building the Checkpoint System
A checkpoint system's job is to record, durably and cheaply, "how far did we get" so a restart resumes at the right position instead of at record zero.
There are three checkpoint granularities to choose from:
- Per-item checkpoint — mark each record's status (
pending,processing,success,failed,dead-lettered) individually. Best for record-by-record batch jobs. - Cursor/offset checkpoint — store a single position marker (last processed ID, pagination token, timestamp). Cheaper, but coarser — if item 500 fails you don't know its individual status.
- Snapshot checkpoint — periodically serialize the entire in-memory job state (useful for stateful, multi-step workflows like an LLM agent chain).
Example schema for per-item checkpointing (DynamoDB)
{
"job_id": "batch-2026-01-14-enrich",
"item_id": "record-8743",
"status": "failed",
"attempt_count": 3,
"last_error": "429 rate_limited",
"last_attempted_at": "2026-01-14T02:13:44Z",
"idempotency_key": "b6f2...",
"ttl": 1739577600
}
On AWS, DynamoDB is a natural fit for checkpoint tables: single-digit-millisecond reads/writes, conditional writes for safe concurrent updates, and native TTL to auto-expire old checkpoint records. Use job_id as the partition key and item_id as the sort key so you can query "all failed items for this job" efficiently with a GSI on status.
For coarser cursor-style checkpoints (e.g., resuming a paginated API pull), a single row per job in DynamoDB or even a small record in Amazon S3 (as a JSON file) is enough — write it after every N items or every M seconds, whichever comes first, to bound how much work you'd redo on a crash.
Reference Architecture on AWS
Here's a concrete, buildable example: a batch enrichment job triggered nightly.
- Amazon EventBridge — cron rule triggers the job nightly.
- AWS Step Functions — orchestrates the workflow as a state machine with built-in
RetryandCatchfields per state — this is arguably the fastest path to a working checkpoint system without writing your own orchestration code. - AWS Lambda — worker function that calls the external API for a batch of records.
- Amazon SQS — queues individual records; failed messages after N receives go to a dead-letter queue (DLQ) automatically.
- DynamoDB — checkpoint table tracking per-item status.
- Amazon CloudWatch — alarms on DLQ depth and error rate.
Step Functions is worth calling out specifically because it gives you retry/backoff and checkpointing almost for free at the state-machine level:
{
"Retry": [
{
"ErrorEquals": ["States.Timeout", "Lambda.TooManyRequestsException"],
"IntervalSeconds": 2,
"MaxAttempts": 5,
"BackoffRate": 2.0,
"JitterStrategy": "FULL"
}
],
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"Next": "RecordFailureAndCheckpoint",
"ResultPath": "$.error"
}
]
}
Step Functions persists execution state automatically (each state transition is durably logged), which means the "checkpoint" for the overall workflow position is handled by the service — you only need your own DynamoDB checkpoint table for per-record granularity within a single state's batch processing.
Comparing Checkpoint Storage Options
Choosing where to persist checkpoint state depends on write volume, query needs, and cost sensitivity.
| Storage | Write latency | Query flexibility | Cost model | Best for |
|---|---|---|---|---|
| DynamoDB | Single-digit ms | High (GSIs, conditional writes) | Pay-per-request or provisioned | Per-item checkpoints, high concurrency |
| S3 (JSON/manifest file) | ~50-200ms | Low (must download/parse) | Very cheap per GB + requests | Cursor checkpoints, infrequent writes |
| RDS/Aurora | ~5-20ms | Very high (SQL joins, transactions) | Instance/serverless cost | Complex checkpoint relationships, existing SQL stack |
| ElastiCache (Redis) | Sub-ms | Medium (key patterns) | Node/hour cost | Ephemeral, high-frequency progress counters |
For most API-heavy batch and pipeline workloads, DynamoDB wins on the latency/cost/flexibility trade-off, especially since checkpoint writes are frequent and small. Redis is attractive if you need sub-millisecond checkpoint updates but remember it's not durable by default — use it as a fast cache in front of a durable store, not as the source of truth, unless you enable AOF persistence and accept the operational overhead.
Worked Example: A Minimal Checkpoint-Aware Worker
This is a simplified but functionally complete example combining error classification, backoff, and checkpointing against DynamoDB.
import boto3
import requests
import time
import random
table = boto3.resource("dynamodb").Table("api-checkpoints")
RETRYABLE_STATUS = {429, 502, 503, 504}
def process_record(job_id, item_id, payload):
checkpoint = table.get_item(Key={"job_id": job_id, "item_id": item_id}).get("Item")
if checkpoint and checkpoint["status"] == "success":
return # already done, skip
attempt = checkpoint["attempt_count"] if checkpoint else 0
for attempt in range(attempt, 5):
try:
resp = requests.post(
"https://api.example.com/v1/enrich",
json=payload,
headers={"Idempotency-Key": f"{job_id}-{item_id}"},
timeout=10
)
if resp.status_code == 200:
write_checkpoint(job_id, item_id, "success", attempt)
return
if resp.status_code in RETRYABLE_STATUS:
delay = min(30, (2 ** attempt)) + random.uniform(0, 1)
time.sleep(delay)
continue
# permanent error - no retry
write_checkpoint(job_id, item_id, "failed_permanent", attempt, error=resp.text)
return
except requests.exceptions.Timeout:
write_checkpoint(job_id, item_id, "failed_timeout", attempt)
delay = min(30, (2 ** attempt))
time.sleep(delay)
write_checkpoint(job_id, item_id, "failed_exhausted", attempt)
def write_checkpoint(job_id, item_id, status, attempt, error=None):
table.put_item(Item={
"job_id": job_id,
"item_id": item_id,
"status": status,
"attempt_count": attempt + 1,
"last_error": error,
"last_attempted_at": int(time.time())
})
This is intentionally minimal — a full API request error handling and checkpoint system example in production would add structured logging (CloudWatch Logs with correlation IDs), metrics per error type (via CloudWatch Embedded Metric Format), and a resume script that queries DynamoDB for all items where status != "success" to reprocess only what's needed.
Build It Yourself vs. Use a Generator/Maker Tool
If you're evaluating a scaffolding tool or "API request error handling and checkpoint system maker" rather than hand-rolling this, weigh what it actually saves you.
Generator tools (low-code workflow builders, AI-assisted code scaffolders, or template repositories) can save real time on boilerplate: the retry decorator, the DynamoDB table definitions, the IAM policies for Lambda-to-DynamoDB access. Draft1.ai-style prompt-to-architecture tools, for instance, are useful for quickly producing the initial AWS diagram and IaC skeleton (Step Functions definition, DynamoDB table, SQS DLQ wiring) so you're not starting from a blank canvas.
What they typically don't do well: encode your specific error taxonomy (which of your upstream API's status codes are actually retryable — some vendors return 400 for what should be a 429), tune backoff ceilings against your actual rate limits, or decide the right checkpoint granularity for your data model. Treat generated scaffolding as a first draft to review, not a finished system — test failure paths explicitly (kill the process mid-batch, confirm resume behavior) before trusting it in production.
Key Takeaways
- Classify errors before retrying — transient (429/502/503/504), permanent (400/401/403/404), and ambiguous (timeouts) each need different handling.
- Use exponential backoff with jitter, not fixed delays, and cap retries with a hard ceiling plus a circuit breaker for sustained outages.
- Idempotency keys are essential for any retried operation that isn't naturally idempotent (payments, order creation).
- DynamoDB is a strong default for per-item checkpoints due to low-latency conditional writes, GSIs for querying failed items, and native TTL cleanup.
- AWS Step Functions gives you workflow-level checkpointing and retry/catch semantics for free — reserve custom checkpoint tables for per-record granularity within a state.
- Test resume behavior explicitly by killing jobs mid-run; a checkpoint system you haven't tested under failure isn't proven to work.
- Generator/maker tools speed up scaffolding but still require you to tune error taxonomy, backoff limits, and checkpoint granularity for your specific API.
Frequently Asked Questions
What's the difference between error handling and checkpointing in an API pipeline?
Error handling decides what to do when a single request fails (retry, skip, abort), while checkpointing decides how to remember progress so the whole job can resume without redoing completed work. They're complementary: good error handling without checkpointing still forces full restarts after a crash.
Should I retry on every HTTP error code?
No — only retry on transient errors like 429, 502, 503, and 504; retrying 400, 401, 403, or 404 wastes time and API quota since the request will fail identically every time. Always check for a Retry-After header on 429 responses and honor it.
How often should I write checkpoints?
Write frequently enough that a crash costs you seconds, not hours — typically after every item for per-item checkpoints, or every N items/M seconds for cursor-based checkpoints. The trade-off is write cost and latency versus rework tolerance; DynamoDB's low per-write cost makes frequent per-item checkpointing affordable for most workloads.
Is DynamoDB required, or can I use a plain database?
DynamoDB isn't required — RDS/Aurora, Redis, or even flat files on S3 all work, and the right choice depends on write frequency, query needs, and existing infrastructure. DynamoDB is commonly preferred for this pattern because of its low-latency conditional writes and built-in TTL, but a team already running Postgres can just as reasonably use a checkpoints table there.
Can AWS Step Functions replace a custom checkpoint system entirely?
For workflow-level progress, yes — Step Functions durably tracks state transitions and supports native Retry/Catch per state. For fine-grained progress within a single state (e.g., tracking 10,000 individual records processed by one Lambda invocation), you still need your own checkpoint table.
What is an idempotency key and why does it matter for retries?
An idempotency key is a unique client-generated identifier sent with a request so that if the same request is retried (e.g., after a timeout), the API returns the original result instead of executing the action twice. It's critical for non-idempotent operations like payments or order creation, where blind retries could cause duplicate charges or duplicate records.
How do I know if my checkpoint system actually works?
Test it by deliberately killing the process mid-batch and confirming it resumes from the last checkpoint rather than the start. Also verify DLQ behavior by forcing a permanent error and checking that it lands in the dead-letter queue rather than retrying indefinitely.
Draw this in seconds with draft1. Describe your architecture in plain English and draft1 generates an editable AWS/cloud diagram plus documentation — no dragging boxes around. Try it free.
Draw this in ~20 seconds
Describe your own version of this architecture and draft1 generates an editable draw.io diagram — boxes, arrows, labels, the lot.
Generate this diagram free ➔Free demo — no signup. Then 3 free diagrams with an account, no card.