- Author: Amin Boulouma, Software Engineer
- Github source code: https://github.com/aminblm/ai_systems_design_from_scratch
- Engineering Blog: https://aminblm.github.io/ai_systems_design_from_scratch/blog/
7 Strategies to Master Idempotent API Design (Without Retrying Blindly)
In distributed systems, the network is never reliable. Imagine a checkout service where a user clicks ‘Pay’ once, but due to a transient network timeout, the client retries the request. If your backend isn’t idempotent, you just charged your customer twice. This is the “Double-Spend” nightmare that haunts every senior engineer.
Glossary for 5-Year-Olds
- Idempotency: Doing the exact same thing multiple times has the same result as doing it once. Like turning a light switch on; it doesn’t matter if you flip it ten times, the light stays on.
- Distributed System: A bunch of computers working together as one big team to finish a job.
- API: A way for two computer programs to talk to each other and share information.
- Database: A giant digital filing cabinet where we store all our important information.
The Problem: The Partial Failure Gap
Most developers assume that if an HTTP request times out, it never reached the server. In reality, the server might have processed the request, updated the database, and the acknowledgment message simply got lost on the way back.
We choose the Idempotency-Key pattern over standard retries because it provides a deterministic “source of truth” for the state of a transaction, whereas naive retries blindly assume the previous state was null.
Architecture Visualization
Simple Example: Basic Token Guard
This approach uses a simple dictionary to track processed keys. Note that in production, this should be a distributed cache like Redis.
class PaymentProcessor:
def __init__(self):
self._processed_keys = set()
def charge(self, idempotency_key, amount):
if idempotency_key in self._processed_keys:
return "Already processed"
# Process logic
self._processed_keys.add(idempotency_key)
return f"Charged {amount}"
# Simple Usage
proc = PaymentProcessor()
print(proc.charge("req_123", 100)) # Charged 100
print(proc.charge("req_123", 100)) # Already processed
Complex Example: Production-Grade State Machine
In an enterprise environment, we must track the status of the request (STARTED, COMPLETED, FAILED) to handle race conditions where a request might be in-flight.
class EnterprisePaymentProcessor:
def __init__(self):
self.ledger = {} # Maps key -> {'status': str, 'result': any}
def process_payment(self, key, amount):
if key in self.ledger:
record = self.ledger[key]
if record['status'] == 'STARTED':
raise Exception("Request in progress")
return record['result']
self.ledger[key] = {'status': 'STARTED', 'result': None}
try:
# Simulate work
result = f"Successfully processed {amount}"
self.ledger[key] = {'status': 'COMPLETED', 'result': result}
return result
except Exception as e:
self.ledger[key] = {'status': 'FAILED', 'result': None}
raise e
Quick Reference: When to use which strategy
| Strategy | Complexity | Best For |
|---|---|---|
| Idempotency-Key | Low | Payment APIs, Order Creation |
| State Machine | High | Complex workflows, multi-step tasks |
| Sequence Numbers | Medium | Event sourcing, stream processing |
Developer Checklist
- Does my API accept a custom
Idempotency-Keyheader? - Are my database operations wrapped in a transaction?
- Do I have a TTL (Time-To-Live) on my idempotency store to prevent memory leaks?
- Does my error handling correctly distinguish between a transient error (retryable) and a logic error?
TL;DR Summary
Idempotency is the cornerstone of reliable distributed systems. By implementing a strict Idempotency-Key check at the entry point of your service, you transform a fragile, state-dependent architecture into a robust one. Always store the result of the first successful request and return it for all subsequent identical requests.