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
python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect app = FastAPI()
Building a WebSocket chat with FastAPI
websockets
broadcast
connection-management
Intermediate
9 steps
python
import time import uuid from django.utils.deprecation import MiddlewareMixin
Attaching per-request context in Django
middleware
request lifecycle
multi-tenancy
Intermediate
7 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
python
import random from typing import Iterator, List
How reservoir sampling picks k items
reservoir-sampling
streaming
randomness
Intermediate
5 steps
php
<?php namespace App\Services;
Building a cached daily leaderboard in Laravel
caching
aggregation
eager-loading
Intermediate
9 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.