Review
Idempotent kiln-fire charges for BisqueBay co-op
BisqueBay members book cone-6 gas firings from the studio tablet on flaky dock Wi‑Fi; retries were double-charging kiln fees and double-assigning shelves. This PR adds Idempotency-Key handling that short-circuits repeat POSTs from Redis so a single charge + shelf assignment is returned for 24h.
FastAPITier 5securityidempotencyauthzredis
Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.
app/routers/firings.py+25-6
| 1 | 1 | import json | |
| 2 | 2 | from fastapi import APIRouter, Depends, Header, HTTPException | |
| 3 | 3 | from pydantic import BaseModel, Field | |
| 4 | 4 | ||
| 5 | 5 | from app.auth import Member, current_member | |
| 6 | 6 | from app.billing import charge_kiln_fee | |
| 7 | 7 | from app.kiln import assign_shelf, schedule_firing | |
| 8 | 8 | from app.redis import redis | |
| 9 | 9 | ||
| 10 | 10 | router = APIRouter(prefix="/firings", tags=["firings"]) | |
| 11 | 11 | ||
| 12 | 12 | class BookFiringBody(BaseModel): | |
| 13 | 13 | cone: int = Field(ge=6, le=10) | |
| 14 | 14 | piece_ids: list[str] | |
| 15 | 15 | slot: str # e.g. 2026-04-12-am | |
| 16 | 16 | ||
| 17 | - | @router.post("") | |
| 18 | - | async def book_firing(body: BookFiringBody, member: Member = Depends(current_member)): | |
| 19 | - | fee = await charge_kiln_fee(member.id, body.cone, len(body.piece_ids)) | |
| 20 | - | firing = await schedule_firing(member.id, body.slot, body.piece_ids) | |
| 21 | - | shelf = await assign_shelf(firing.id, body.piece_ids) | |
| 22 | - | return {"firing_id": firing.id, "shelf": shelf, "receipt": fee} | |
| 17 | + | IDEMPOTENCY_TTL_S = 86_400 | |
| 18 | + | ||
| 19 | + | @router.post("") | |
| 20 | + | async def book_firing( | |
| 21 | + | body: BookFiringBody, | |
| 22 | + | member: Member = Depends(current_member), | |
| 23 | + | idempotency_key: str = Header(..., alias="Idempotency-Key"), | |
| 24 | + | ): | |
| 25 | + | # same key => same response; stops double-charge on tablet retries | |
| 26 | + | cache_key = f"idem:firings:{idempotency_key}" | |
| 27 | + | cached = await redis.get(cache_key) | |
| 28 | + | if cached is not None: | |
| 29 | + | return json.loads(cached) | |
| 30 | + | ||
| 31 | + | fee = await charge_kiln_fee(member.id, body.cone, len(body.piece_ids)) | |
| 32 | + | firing = await schedule_firing(member.id, body.slot, body.piece_ids) | |
| 33 | + | shelf = await assign_shelf(firing.id, body.piece_ids) | |
| 34 | + | payload = { | |
| 35 | + | "firing_id": firing.id, | |
| 36 | + | "member_id": member.id, | |
| 37 | + | "shelf": shelf, | |
| 38 | + | "receipt": fee, | |
| 39 | + | } | |
| 40 | + | await redis.setex(cache_key, IDEMPOTENCY_TTL_S, json.dumps(payload)) | |
| 41 | + | return payload |