Introduction
Network failures happen. When a client doesn't receive a response, should they retry? Idempotent APIs handle retries gracefully without causing duplicate effects.
Key Concepts
Idempotency: Making the same request multiple times has the same effect as making it once.
Idempotency Key: Client-generated unique ID to identify duplicate requests.
Safe Methods: GET, HEAD, OPTIONS don't modify data—inherently safe to retry.
Real World Context
Idempotency is critical for:
- Payment processing: Stripe requires idempotency keys to prevent duplicate charges
- Order placement: E-commerce APIs must prevent duplicate orders from retries
- Webhook delivery: Webhook receivers must handle duplicate deliveries gracefully
- Distributed systems: Network partitions and retries are common in microservice architectures
Deep Dive
Idempotent HTTP Methods
GET - Idempotent (no side effects)
PUT - Idempotent (same result each time)
DELETE - Idempotent (resource stays deleted)
POST - NOT idempotent (creates duplicates)
PATCH - NOT guaranteed idempotent (depends on patch format)
Idempotency Keys for POST
Clients include an Idempotency-Key header with a unique value (typically a UUID). The server caches the response and returns it unchanged on retry:
pythondef create_order(request): idempotency_key = request.headers.get('Idempotency-Key') if not idempotency_key: return api_error('MISSING_IDEMPOTENCY_KEY', 'Idempotency-Key header required', status=400) # Check for existing request with this key cache_key = f'idempotency:{request.user.id}:{idempotency_key}' cached_response = cache.get(cache_key) if cached_response: return JsonResponse(cached_response) # Return same response # Process the request order = Order.objects.create( user=request.user, idempotency_key=idempotency_key, **order_data ) response_data = {'id': order.id, 'status': 'created'} cache.set(cache_key, response_data, 86400) # Cache for 24 hours return JsonResponse(response_data, status=201)
The cache TTL of 24 hours means clients can safely retry within that window. After 24 hours, the key expires and can be reused.
Database-Level Uniqueness
For critical operations like payments, back the idempotency key with a database unique constraint for durability beyond cache TTL:
python# models.py class Order(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) idempotency_key = models.CharField(max_length=64) # ... class Meta: unique_together = ['user', 'idempotency_key'] # views.py from django.db import IntegrityError def create_order(request): try: order = Order.objects.create( user=request.user, idempotency_key=data['idempotency_key'], **order_data ) except IntegrityError: # Duplicate - return existing order order = Order.objects.get( user=request.user, idempotency_key=data['idempotency_key'] ) return JsonResponse({'id': order.id})
The unique_together constraint on (user, idempotency_key) catches duplicates at the database level, even if the cache entry has expired. The IntegrityError handler returns the existing order instead of creating a duplicate.
Common Pitfalls
-
Not requiring idempotency keys for mutations: POST endpoints that create resources should require idempotency keys.
-
Infinite key storage: Idempotency keys should expire (24-48 hours). Don't store them forever.
-
Caching only the response, not the status code: A cached 201 response should be returned with the same 201 status, not 200.
Best Practices
- Require idempotency keys for POST: Especially for payments and important operations.
- Store results: Cache or database to return consistent responses.
- Time-limited keys: Expire old keys to prevent storage bloat.
- Document clearly: Explain idempotency behavior in API docs.
Summary
Idempotency ensures safe retries. GET/PUT/DELETE are naturally idempotent; for POST, implement idempotency keys that let clients safely retry requests without creating duplicates.
Code Examples
from django.core.cache import cache
def create_order(request):
idempotency_key = request.headers.get('Idempotency-Key')
if not idempotency_key:
return JsonResponse({'error': 'Idempotency-Key required'}, status=400)
cache_key = f'idempotency:{request.user.id}:{idempotency_key}'
cached = cache.get(cache_key)
if cached:
return JsonResponse(cached) # Return same response
order = Order.objects.create(user=request.user, **order_data)
response_data = {'id': order.id, 'status': 'created'}
cache.set(cache_key, response_data, 86400)
return JsonResponse(response_data, status=201)