Skip to content

Incident: duplicate records from retries

A timeout is not a failure: the abandoned request finishes, its resend repeats the effect; key the intent, pass the key on, clean, then constrain.

Predict first

Before you read: predict

PAGE · 3 October, 11:09 · ExamHall registration for the ECON-201 session · the payment provider has answered slowly since 11:00

Candidate app 3.8 went out on 1 October. Its Register button runs:

The registration endpoint charges the 49 € fee through the payment provider, then inserts one row into registrations, then answers 201. Since 11:00 the provider has taken 12 s to answer each charge. A candidate taps Register once, at 11:02.

Once the provider has answered every charge, what has happened?

Code for this question
async function register(sessionId) {  for (let attempt = 1; attempt <= 2; attempt++) {    try {      const res = await fetch('/api/registrations', {        method: 'POST',        headers: { 'content-type': 'application/json' },        body: JSON.stringify({ sessionId }),        signal: AbortSignal.timeout(10_000),      });      if (res.status === 201) return 'Registered';    } catch {}  }  return 'Registration failed';}
Pick one

The 30-second version

One tap, two rows, two charges, an app that said "failed": a resend, not the user. The request the app gave up on still finishes; the resend runs it again. Find the pairs by what a tap means; confirm by their gap and release. One key per tap, passed on to the provider; clean the pairs, then constrain.

A timeout is not a failure: the abandoned request finishes, its resend repeats the effect; key the intent, pass the key on, clean, then constrain.

How it works

Step 1 of 12

The symptom. One tap left two rows and two charges, and the app said the tap failed. Nobody tapped twice.
In the kitchen
  • two sweeps arrive, each charging a call-out fee, though she was told nothing was booked → two rows, two charges, "Registration failed" (step 1)

Analogy: A chimney sweeps' booking line

A householder rings the sweeps' association to book a sweep. The clerk puts her on hold while radioing the sweeps, who are all up ladders this morning.

On the booking line In the incident
ten minutes of hold music, then she rings off and calls again the app's timeout and resend (steps 6–7)
a second clerk books a second sweep the resend runs the whole operation again
two sweeps arrive, each charging a call-out fee, though she was told nothing was booked two rows, two charges, "Registration failed" (step 1)
the two bookings on the day sheet, ten minutes apart the pair's gap equals the timeout (step 4)
a job number she is given when she first asks, and reads out on every call one key per intent (step 9)
the clerk reads the number to the sweeps' rota, so a sweep hearing a taken number stands down the key passed on to the provider (step 10)
a "one line per job number" rule, written only after the doubled lines are struck out and refunded clean, then constrain (steps 11–12)

Breaks down when: a clerk recognises a caller's voice. An API cannot: unless the request carries the number, two calls with the same words are two bookings.

Contrasting case: a candidate who taps Register twice. The rows pair up too, but they sit as far apart as her taps and appear on every release. Each tap makes its own key, so a key per tap keeps them apart: to the API they are two intents.

In real terms: after a page about doubled records, measure the gap and group by release before reading code, and fix every hop that resends, not only the app.

Examples, run for real

4.1 ExamHall EXH-2980: registration during the 11:00 slowdown

Candidate app 3.8 registers 80 candidates for the ECON-201 session, one tap every 15 s from 10:50. From 11:00:00 to 11:09:15 the provider takes 12 s per charge, otherwise 2 s:

4.1 PostgreSQL 17.11 · app 3.8's Register code · 80 ECON-201 taps, one every 15 s from 10:50:00 · provider 12 s per charge from 11:00:00 to 11:09:15, 2 s otherwise
4.1 taps 80 · requests sent 117 · registrations 117 · provider charges 117
4.1 candidates with two ECON-201 registrations: 37 · charged twice: 37 · their app showed "Registration failed": 37 of 37
4.1 each pair's two rows created 10 s apart, the app's timeout: 37 of 37
4.1 the other 43 taps: one registration, one charge, "Registered": 43 of 43

Every candidate whose tap met the slowdown has two rows and two charges, and was told the registration failed (steps 1 and 6). Each pair's rows sit exactly the app's 10 s timeout apart, so the second row is the resend, not a second tap (step 4).

4.2 Chat MR !3280: clean the pairs, then constrain

A chat app's release 7.1 already gave every send one id, made at the tap and reused by its resend, but the send endpoint stored it without checking it:

async function send(roomId, text) {
  const clientMsgId = crypto.randomUUID();   // once per tap; the resend reuses it
  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      const res = await fetch(`/api/rooms/${roomId}/messages`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ text, clientMsgId }),
        signal: AbortSignal.timeout(5_000),
      });
      if (res.status === 201) return 'sent';
    } catch {}
  }
  return 'not sent';
}

MR !3280 first removes the later copy of each pair, then builds the unique index without blocking writes and attaches it, each statement on its own:

DELETE FROM messages a USING messages b
  WHERE a.client_msg_id = b.client_msg_id AND a.id > b.id;
CREATE UNIQUE INDEX CONCURRENTLY messages_client_msg_id_key ON messages (client_msg_id);
ALTER TABLE messages ADD CONSTRAINT messages_client_msg_id_key UNIQUE USING INDEX messages_client_msg_id_key;

Then the endpoint inserts first, and asks the push service only for a new row, passing it the id as Idempotency-Key:

INSERT INTO messages (room_id, author_id, body, client_msg_id, created_at)
  VALUES ($1, $2, $3, $4, now())
  ON CONFLICT (client_msg_id) DO NOTHING;
4.2 PostgreSQL 17.11 · release 7.1 (one client_msg_id per tap, reused by the resend) · 9 sends 20:02-20:10 · push service 8 s per call
4.2 before MR !3280: messages 18 · client_msg_ids written twice 9 · push notifications 18
4.2 MR !3280 repair, each statement on its own: DELETE FROM messages · CREATE UNIQUE INDEX · ALTER TABLE messages · all ok
4.2 after the repair: messages 9 · client_msg_ids written twice 0 · unique constraints 1 · every index valid: true
4.2 the same slowdown after MR !3280, 9 new sends: messages 9 · push notifications 9 · the app showed "sent": 9 of 9

With the pairs gone, the index builds and the constraint attaches (steps 11–12). In the next slowdown each send writes one row and one notification, and the resend is answered at once, so the app shows "sent" (steps 9–10).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A chat app's release 7.2 sends a message with:

The send endpoint asks the push service to notify the room, then inserts the message with created_at = now(), then answers 201; it gives up on its push call after 2 s, sends it once more and waits for that one. From 20:00 to 20:30 the push service took 8 s to answer each call, and 1 s after that. Between 20:02 and 20:40, 12 people each sent a message, and every message is stored twice. The on-call engineer lists each doubled message:

author_id  first row  seconds to the second row
user-01    20:02:10   5
user-02    20:03:10   5
user-03    20:04:10   5
user-04    20:05:10   5
user-05    20:06:10   5
user-06    20:07:10   5
user-07    20:08:10   5
user-08    20:09:10   5
user-09    20:10:10   5
user-10    20:33:01   2
user-11    20:36:01   1
user-12    20:39:01   3

What made the 3 pairs that are not 5 s apart?

Code for this question
async function send(roomId, text) {  for (let attempt = 1; attempt <= 2; attempt++) {    try {      const res = await fetch(`/api/rooms/${roomId}/messages`, {        method: 'POST',        headers: { 'content-type': 'application/json' },        body: JSON.stringify({ text }),        signal: AbortSignal.timeout(5_000),      });      if (res.status === 201) return 'sent';    } catch {}  }  return 'not sent';}
Pick one

Question 2 of 7

A wallet app's Top up button runs:

The top-up endpoint charges the card through the card provider, then inserts one ledger entry, then answers 201. Its own call to the provider gives up after 8 s, waits 5 s and sends the charge once more. The provider honours an Idempotency-Key header: it saves the first result for a key and answers a later call with that key from it. Tonight the provider takes 12 s to answer each charge.

Which change leaves each tap with exactly one card charge?

Code for this question
async function topUp(amountCents) {  for (let attempt = 1; attempt <= 2; attempt++) {    try {      const res = await fetch('/api/topups', {        method: 'POST',        headers: { 'content-type': 'application/json' },        body: JSON.stringify({ amountCents }),        signal: AbortSignal.timeout(10_000),      });      if (res.status === 201) return 'Topped up';    } catch {}  }  return 'Top-up failed';}
Pick one

Question 3 of 7

A ticketing app resent seat holds after a timeout, and the holds table had 23 pairs of rows that share an order_ref. The team deleted the later row of each pair, then ran, outside any transaction block:

CREATE UNIQUE INDEX CONCURRENTLY holds_order_ref_key ON holds (order_ref);

The build finished. Nobody ran the ALTER TABLE … ADD CONSTRAINT … USING INDEX step that was to follow. Now a client resends a hold, and the endpoint runs

INSERT INTO holds (order_ref, seat) VALUES ($1, $2) ON CONFLICT (order_ref) DO NOTHING;

with an order_ref that is already in the table.

What happens?

Pick one

Question 4 of 7

A streaming service is rolling out its app's release 5.4 to half its users; the other half still run 5.3. Each release's Subscribe button:

// release 5.4
async function subscribe(plan) {
  try {
    const res = await fetch('/api/subscriptions', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ plan }),
      signal: AbortSignal.timeout(30_000),
    });
    if (res.status === 201) return 'Subscribed';
  } catch {}
  return 'Something went wrong';
}

The endpoint charges the first month through the payment provider, then inserts the subscription, then answers 201. During a 20-minute billing slowdown, when every charge took 15 s, 20 accounts subscribed, 10 on each release, each with one tap. The on-call engineer lists the accounts charged more than once, grouped by the release in each request's User-Agent.

What does she see?

Code for this question
// release 5.3async function subscribe(plan) {  for (let attempt = 1; attempt <= 2; attempt++) {    try {      const res = await fetch('/api/subscriptions', {        method: 'POST',        headers: { 'content-type': 'application/json' },        body: JSON.stringify({ plan }),        signal: AbortSignal.timeout(10_000),      });      if (res.status === 201) return 'Subscribed';    } catch {}  }  return 'Something went wrong';}
Pick one

Question 5 of 7

In the interview you are given EXH-2980's page and asked: "Candidates were charged twice.

Walk me through the cause and the repair." What do you answer, in at most 150 words?

Question 6 of 7

ExamHall's registration endpoint charges the fee, then inserts a row into registrations (id, candidate_id, session_id, status, charge_id, created_at); status is 'active', or 'cancelled' once the candidate cancels that registration. On 3 October, during a payment-provider slowdown, app 3.8's resend charged some candidates twice for one tap. The on-call engineer drafts three versions of one SELECT … GROUP BY … HAVING count(*) > 1 query.

Which lists exactly the candidates charged twice for one tap?

Pick one

Question 7 of 7

After a resend incident, messages holds 12 pairs of rows that share a client_msg_id, and nobody has cleaned them yet. It is 09:00 UTC on 7 October. A teammate wants repeats refused from now on, while the old pairs wait for the clean-up.

Which statement does that?

Pick one

Go deeper

Pro tips
  1. Test with a slow dependency, not a dead one. Point a test's provider at a stub that answers after the client's timeout, then count rows and charges per tap. A dead stub fails fast and hides the repeat. When: before shipping any client that retries a write. Status: EXECUTED.
  2. Save the refund list before the clean-up. Export each extra row's charge id before the DELETE that removes it; the DELETE is what loses the list. When: step 11, before any repair statement. Status: advice; the export itself is not executed here.
Source

Provider contracts and PostgreSQL behaviour can change: the facts below are as of 2026-10-06. No item is keyed on a source; every key is executed. Valid as of 2026-10-06.

  • Stripe API reference, Idempotent requests: https://docs.stripe.com/api/idempotent_requests (stripe-idempotent-requests.txt), the curriculum row's second source. Quoted: "Stripe’s idempotency works by saving the resulting status code and body of the first request made for any given idempotency key, regardless of whether it succeeds or fails."; "If incoming parameters fail validation, or the request conflicts with another request that’s executing concurrently, we don’t save the idempotent result". Supports step 10 and the provider stand-in's rules.
  • IETF, The Idempotency-Key HTTP Header Field, draft-ietf-httpapi-idempotency-key-header-07: https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-07.html (ietf-idempotency-key-07.txt). Its datatracker page, the curriculum row's first source (https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/, ietf-idempotency-key-header.txt), lists it as an "Expired Internet-Draft", last updated 2026-04-18: work in progress, not an RFC. Quoted: "Consider a scenario where the client sent a POST request to the server, but the request timed out. The client is left uncertain about the status of the resource."; "If the request is retried, while the original request is still being processed, the resource SHOULD reply with an HTTP 409 status code". Supports steps 6 and 10 and the stand-in's 409.
  • PostgreSQL 17 documentation, ALTER TABLE: https://www.postgresql.org/docs/17/sql-altertable.html (pg-altertable.txt), the row's third source. Quoted: "plus the option NOT VALID, which is currently only allowed for foreign key and CHECK constraints"; ADD table_constraint_using_index "adds a new PRIMARY KEY or UNIQUE constraint to a table based on an existing unique index." Supports steps 11–12.
  • PostgreSQL 17 documentation, CREATE INDEX, Building Indexes Concurrently: https://www.postgresql.org/docs/17/sql-createindex.html (pg-createindex.txt). Quoted: "If a problem arises while scanning the table, such as a deadlock or a uniqueness violation in a unique index, the CREATE INDEX command will fail but leave behind an “invalid” index." Supports step 12.

Checked against the tools and docs as of 2026-10-06.