|
1 | | -"""Idempotency validation middleware for mutating API requests.""" |
| 1 | +"""Idempotency middleware. |
| 2 | +
|
| 3 | +Requires an Idempotency-Key on mutating requests, and — for authenticated |
| 4 | +requests — stores the response the first time a key is seen and *replays* it |
| 5 | +when the same key is reused. This makes POST/PATCH/DELETE safe to retry: |
| 6 | +
|
| 7 | + - same key + same request body -> the stored response is replayed |
| 8 | + - same key + different body -> 409 (the key was reused for a new request) |
| 9 | + - key still in flight -> 409 (a concurrent request holds it) |
| 10 | +
|
| 11 | +The store lives in idempotency_records (one row per merchant+key+method+path). |
| 12 | +""" |
2 | 13 |
|
3 | 14 | from __future__ import annotations |
4 | 15 |
|
5 | | -from collections.abc import Callable |
| 16 | +import hashlib |
| 17 | +import json |
| 18 | +import logging |
6 | 19 |
|
7 | 20 | from fastapi import Request |
| 21 | +from sqlalchemy import select |
| 22 | +from sqlalchemy.exc import IntegrityError |
8 | 23 | from starlette.middleware.base import BaseHTTPMiddleware |
9 | 24 | from starlette.responses import Response |
10 | 25 |
|
11 | 26 | from somba.api.errors import APIError, error_response |
| 27 | +from somba.db.models import IdempotencyRecord, IdempotencyRecordStatus, Merchant |
| 28 | +from somba.db.session import get_db |
| 29 | +from somba.security import parse_api_key, verify_api_key_secret |
| 30 | + |
| 31 | +log = logging.getLogger(__name__) |
12 | 32 |
|
13 | 33 | MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} |
14 | 34 | IDEMPOTENCY_EXEMPT = {"/v1/webhooks/nomba"} |
15 | 35 |
|
16 | 36 |
|
17 | 37 | class IdempotencyMiddleware(BaseHTTPMiddleware): |
18 | | - """Require an idempotency key for mutating requests.""" |
| 38 | + """Require an idempotency key and replay stored responses on repeat keys.""" |
| 39 | + |
| 40 | + async def dispatch(self, request: Request, call_next): |
| 41 | + if request.method not in MUTATING_METHODS or request.url.path in IDEMPOTENCY_EXEMPT: |
| 42 | + return await call_next(request) |
19 | 43 |
|
20 | | - async def dispatch(self, request: Request, call_next: Callable[[Request], Response]) -> Response: |
21 | | - if request.method in MUTATING_METHODS and request.url.path not in IDEMPOTENCY_EXEMPT: |
22 | | - key = request.headers.get("Idempotency-Key", "").strip() |
23 | | - if not key: |
24 | | - error = APIError( |
| 44 | + key = request.headers.get("Idempotency-Key", "").strip() |
| 45 | + if not key: |
| 46 | + return error_response( |
| 47 | + APIError( |
25 | 48 | code="missing_idempotency_key", |
26 | 49 | message="Mutating requests require an Idempotency-Key header", |
27 | 50 | status_code=400, |
28 | 51 | param="Idempotency-Key", |
29 | 52 | ) |
30 | | - return error_response(error) |
31 | | - request.state.idempotency_key = key |
| 53 | + ) |
| 54 | + request.state.idempotency_key = key |
| 55 | + |
| 56 | + merchant_id = self._resolve_merchant_id(request) |
| 57 | + if merchant_id is None: |
| 58 | + # Unauthenticated / invalid key: let the route's auth return 401. |
| 59 | + return await call_next(request) |
| 60 | + |
| 61 | + # Reading the body here is safe — Starlette's BaseHTTPMiddleware caches |
| 62 | + # it and replays it to the downstream handler. |
| 63 | + body = await request.body() |
| 64 | + request_hash = hashlib.sha256( |
| 65 | + request.method.encode() + b"|" + request.url.path.encode() + b"|" + body |
| 66 | + ).hexdigest() |
| 67 | + lookup = (merchant_id, key, request.method, request.url.path) |
| 68 | + |
| 69 | + # --- claim the key, or replay an existing result --- |
| 70 | + db, gen = self._session(request) |
| 71 | + try: |
| 72 | + existing = self._fetch(db, lookup) |
| 73 | + if existing is not None: |
| 74 | + replay = self._maybe_replay(existing, request_hash) |
| 75 | + if replay is not None: |
| 76 | + return replay |
| 77 | + # A 'failed' record: drop it and let this attempt retry. |
| 78 | + db.delete(existing) |
| 79 | + db.commit() |
| 80 | + |
| 81 | + db.add( |
| 82 | + IdempotencyRecord( |
| 83 | + merchant_id=merchant_id, |
| 84 | + idempotency_key=key, |
| 85 | + method=request.method, |
| 86 | + path=request.url.path, |
| 87 | + request_hash=request_hash, |
| 88 | + status=IdempotencyRecordStatus.in_progress, |
| 89 | + ) |
| 90 | + ) |
| 91 | + try: |
| 92 | + db.commit() |
| 93 | + except IntegrityError: |
| 94 | + # A concurrent request claimed the same key first. |
| 95 | + db.rollback() |
| 96 | + return self._conflict("A request with this Idempotency-Key is already in progress") |
| 97 | + finally: |
| 98 | + self._close(gen) |
| 99 | + |
| 100 | + # --- run the real handler --- |
| 101 | + try: |
| 102 | + response = await call_next(request) |
| 103 | + except Exception: |
| 104 | + # Handler blew up: release the key so the client can retry. |
| 105 | + self._release(request, lookup) |
| 106 | + raise |
| 107 | + |
| 108 | + raw = b"".join([chunk async for chunk in response.body_iterator]) |
| 109 | + |
| 110 | + # --- persist the response (2xx) or release the key (anything else) --- |
| 111 | + db, gen = self._session(request) |
| 112 | + try: |
| 113 | + rec = self._fetch(db, lookup) |
| 114 | + if rec is not None: |
| 115 | + if 200 <= response.status_code < 300: |
| 116 | + rec.status = IdempotencyRecordStatus.completed |
| 117 | + rec.response_status = response.status_code |
| 118 | + rec.response_body = _safe_json(raw) |
| 119 | + else: |
| 120 | + db.delete(rec) |
| 121 | + db.commit() |
| 122 | + finally: |
| 123 | + self._close(gen) |
| 124 | + |
| 125 | + headers = dict(response.headers) |
| 126 | + headers.pop("content-length", None) |
| 127 | + return Response( |
| 128 | + content=raw, |
| 129 | + status_code=response.status_code, |
| 130 | + headers=headers, |
| 131 | + media_type=response.media_type, |
| 132 | + ) |
| 133 | + |
| 134 | + # ------------------------------------------------------------------ helpers |
| 135 | + |
| 136 | + def _session(self, request: Request): |
| 137 | + """Resolve a DB session the same way FastAPI does — honouring overrides.""" |
| 138 | + dep = request.app.dependency_overrides.get(get_db, get_db) |
| 139 | + gen = dep() |
| 140 | + return next(gen), gen |
| 141 | + |
| 142 | + @staticmethod |
| 143 | + def _close(gen) -> None: |
| 144 | + try: |
| 145 | + next(gen) |
| 146 | + except StopIteration: |
| 147 | + pass |
| 148 | + |
| 149 | + @staticmethod |
| 150 | + def _fetch(db, lookup): |
| 151 | + merchant_id, key, method, path = lookup |
| 152 | + return db.scalar( |
| 153 | + select(IdempotencyRecord).where( |
| 154 | + IdempotencyRecord.merchant_id == merchant_id, |
| 155 | + IdempotencyRecord.idempotency_key == key, |
| 156 | + IdempotencyRecord.method == method, |
| 157 | + IdempotencyRecord.path == path, |
| 158 | + ) |
| 159 | + ) |
| 160 | + |
| 161 | + def _release(self, request: Request, lookup) -> None: |
| 162 | + db, gen = self._session(request) |
| 163 | + try: |
| 164 | + rec = self._fetch(db, lookup) |
| 165 | + if rec is not None: |
| 166 | + db.delete(rec) |
| 167 | + db.commit() |
| 168 | + finally: |
| 169 | + self._close(gen) |
| 170 | + |
| 171 | + def _resolve_merchant_id(self, request: Request) -> int | None: |
| 172 | + header = request.headers.get("Authorization", "") |
| 173 | + if not header.startswith("Bearer "): |
| 174 | + return None |
| 175 | + token = header.removeprefix("Bearer ").strip() |
| 176 | + try: |
| 177 | + public_id, secret = parse_api_key(token) |
| 178 | + except ValueError: |
| 179 | + return None |
| 180 | + db, gen = self._session(request) |
| 181 | + try: |
| 182 | + merchant = db.scalar(select(Merchant).where(Merchant.api_key_id == public_id)) |
| 183 | + if merchant is None or not verify_api_key_secret(secret, merchant.api_key_hash): |
| 184 | + return None |
| 185 | + return merchant.id |
| 186 | + finally: |
| 187 | + self._close(gen) |
| 188 | + |
| 189 | + def _maybe_replay(self, existing: IdempotencyRecord, request_hash: str): |
| 190 | + if existing.status == IdempotencyRecordStatus.completed: |
| 191 | + if existing.request_hash == request_hash: |
| 192 | + body = existing.response_body |
| 193 | + content = json.dumps(body).encode() if body is not None else b"" |
| 194 | + resp = Response( |
| 195 | + content=content, |
| 196 | + status_code=existing.response_status or 200, |
| 197 | + media_type="application/json", |
| 198 | + ) |
| 199 | + resp.headers["Idempotency-Replayed"] = "true" |
| 200 | + return resp |
| 201 | + return self._conflict( |
| 202 | + "Idempotency-Key was reused with a different request body", |
| 203 | + code="idempotency_key_reuse", |
| 204 | + ) |
| 205 | + if existing.status == IdempotencyRecordStatus.in_progress: |
| 206 | + return self._conflict("A request with this Idempotency-Key is already in progress") |
| 207 | + return None # failed -> caller drops it and retries |
| 208 | + |
| 209 | + @staticmethod |
| 210 | + def _conflict(message: str, code: str = "idempotency_conflict"): |
| 211 | + return error_response( |
| 212 | + APIError(code=code, message=message, status_code=409, param="Idempotency-Key") |
| 213 | + ) |
| 214 | + |
32 | 215 |
|
33 | | - return await call_next(request) |
| 216 | +def _safe_json(raw: bytes): |
| 217 | + if not raw: |
| 218 | + return None |
| 219 | + try: |
| 220 | + return json.loads(raw) |
| 221 | + except ValueError: |
| 222 | + return None |
0 commit comments