python
51 lines · 9 steps
Idempotent payment endpoints in FastAPI
How Redis locks and cached results make a payment POST safe to retry without double-charging.
Explained by
highlit
1import json
2import hashlib
3from datetime import timedelta
4
5import redis.asyncio as redis
6from fastapi import APIRouter, Header, HTTPException, Request, Response, status
7
8router = APIRouter()
9rdb = redis.Redis(host="localhost", decode_responses=True)
10
11LOCK_TTL = 30
12RESULT_TTL = timedelta(hours=24)
13
14
15def _fingerprint(method: str, path: str, body: bytes) -> str:
16 return hashlib.sha256(method.encode() + path.encode() + body).hexdigest()
17
18
19@router.post("/payments", status_code=status.HTTP_201_CREATED)
20async def create_payment(
21 request: Request,
22 idempotency_key: str = Header(..., alias="Idempotency-Key"),
23):
24 body = await request.body()
25 fp = _fingerprint(request.method, request.url.path, body)
26 lock_key = f"idem:lock:{idempotency_key}"
27 result_key = f"idem:result:{idempotency_key}"
28
29 cached = await rdb.get(result_key)
30 if cached is not None:
31 stored = json.loads(cached)
32 if stored["fingerprint"] != fp:
33 raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Key reused with different payload")
34 return Response(content=stored["body"], status_code=stored["status"], media_type="application/json")
35
36 acquired = await rdb.set(lock_key, fp, nx=True, ex=LOCK_TTL)
37 if not acquired:
38 raise HTTPException(status.HTTP_409_CONFLICT, "Request with this Idempotency-Key is in progress")
39
40 try:
41 payload = json.loads(body)
42 payment = await charge_payment(payload)
43 response_body = json.dumps(payment)
44 await rdb.set(
45 result_key,
46 json.dumps({"fingerprint": fp, "status": 201, "body": response_body}),
47 ex=RESULT_TTL,
48 )
49 return Response(content=response_body, status_code=201, media_type="application/json")
50 finally:
51 await rdb.delete(lock_key)
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1An idempotency key plus a stored request fingerprint lets you safely deduplicate retries while rejecting key reuse with a different payload.
- 2A short-TTL Redis lock set with NX prevents two concurrent requests from both performing the side effect.
- 3Always release the in-progress lock in a finally block so a failure doesn't wedge the key forever.
Related explainers
ruby
class LRUCache def initialize(capacity) raise ArgumentError, "capacity must be positive" unless capacity.positive?
How an LRU cache works in Ruby
caching
eviction
hash-ordering
Intermediate
7 steps
python
import re from collections import Counter from pathlib import Path
Ranking the top words in a PDF
text-processing
regex
counting
Intermediate
6 steps
python
import django_filters from django import forms from django.utils import timezone
Building a date-range FilterSet in Django
filtering
querysets
form-widgets
Intermediate
6 steps
python
from flask import Blueprint, jsonify, request, abort v1 = Blueprint("users_v1", __name__) v2 = Blueprint("users_v2", __name__)
Versioning a Flask API with Blueprints
api versioning
blueprints
rest
Intermediate
7 steps
java
public class SlidingLogRateLimiter { private final int maxRequests; private final long windowMillis;
How a sliding-log rate limiter works
rate-limiting
concurrency
sliding-window
Advanced
8 steps
go
package theme import ( "fmt"
Per-tenant HTML templates with Gin's renderer
multi-tenancy
concurrency
html-templates
Advanced
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/idempotent-payment-endpoints-in-fastapi-explained-python-7009/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.