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
11import json
22from fastapi import APIRouter, Depends, Header, HTTPException
33from pydantic import BaseModel, Field
44
55from app.auth import Member, current_member
66from app.billing import charge_kiln_fee
77from app.kiln import assign_shelf, schedule_firing
88from app.redis import redis
99
1010router = APIRouter(prefix="/firings", tags=["firings"])
1111
1212class BookFiringBody(BaseModel):
1313 cone: int = Field(ge=6, le=10)
1414 piece_ids: list[str]
1515 slot: str # e.g. 2026-04-12-am
1616
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