backendgym backendgym — projects Sign in with GitHubSign in

projects /01-pay-once

# The payment that happened twice

A mobile timeout made one customer pay twice. Make retries safe without hiding a changed request behind the same key.

Warmup ~4h PythonFastAPI
pay-once / spec.md v1.0

The incident

Ledgerloop is a fictional subscription platform. Its POST /v1/charges endpoint sits on the last step of checkout, which handles about 18% of the company's monthly revenue.

On Friday, a mobile carrier dropped responses for eleven seconds. Clients retried 240 charge requests, and 17 customers were charged twice. Support refunded $1,860 that afternoon, while the finance team paused the settlement export for two hours. The API had accepted both attempts because it had no way to tell a retry from a new payment.

Your job is to make the endpoint safe to retry. Keep the existing charge path and response contract. Do not redesign the payment provider or build a durable multi-region ledger in this lab.

Lab boundary

The API and its in-memory charge store are implemented. The retry storm is simulated by concurrent local clients. A production storage choice, replication story, and provider reconciliation job belong in the write-up; the lab measures the request boundary and its local idempotency behavior.

Requirements

What must be true — the how is yours.

  1. R1 Repeated requests with the same idempotency key and the same body return the original charge result without creating another charge. rehearsed by D1 · D2
  2. R2 hidden until you ask rehearsed by D3 · D4

Technical guidance

  • Treat the key and request body as one contract: a replay is safe only when the stored request matches.
  • Keep the charge creation and the record that protects it in one atomic decision, even if the starter store is local.
  • Measure the retry storm after the behavior works; a passing single-threaded example is not enough.

Definition of done

All of them, or it isn't shipped.

  • Both responses describe the same charge, and the second request does not create a new charge. — not recorded yet D1
  • duplicate charges under 0 charges at the same load — not recorded yet D2
  • The second request returns a conflict and the original charge remains the only charge for that key. — not recorded yet D3
  • The expired key can be used for a new charge, while the old charge remains recorded. — not recorded yet D4
  • The existing charge response and validation behavior remain compatible with the starter tests. — not recorded yet
  • The README explains the chosen persistence and expiry trade-off for a real deployment. — not recorded yet

nothing recorded yet — running the drills fills these in

spec v1.0 · drafted with AI, human-reviewed

Solutions

No solutions published yet. Complete the project and yours opens the thread.