A05: Add server-side idempotency for catch report creation via Idempotency-Key header

This commit is contained in:
ik
2026-09-11 07:35:01 +07:00
parent 57aa3ffafb
commit 641374ddbc
4 changed files with 52 additions and 3 deletions
+31 -2
View File
@@ -459,9 +459,32 @@ def admin_reject_external_observation(
@app.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted:
def create_catch_report(
payload: CatchReportCreate, request: Request, db: Db,
idempotency_key: Annotated[str | None, Header()] = None,
) -> CatchReportAccepted:
if payload.website:
raise HTTPException(status_code=400, detail="invalid submission")
# A05: Server-side idempotency — check BEFORE rate limit to avoid polluting table
if idempotency_key:
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
# Force refresh from database to see committed data from previous requests
db.expire_all()
existing = db.scalar(
select(SubmissionAttempt).where(
SubmissionAttempt.idempotency_key == key_hash,
SubmissionAttempt.created_at >= cutoff,
)
)
if existing is not None:
# Return 200 with idempotent flag — client can retry safely
logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]})
return JSONResponse(
status_code=200,
content={"id": "00000000-0000-0000-0000-000000000000", "moderation_status": "pending", "screenshot_upload_token": "", "idempotent": True},
)
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
_check_rate_limit(request, db)
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
@@ -482,7 +505,13 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) ->
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
db.add(report)
db.commit()
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token)
# Store idempotency key if provided
if idempotency_key:
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))
db.commit()
logger.info("idempotency key stored", extra={"idempotency_key": idempotency_key[:8]})
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
@app.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)