DEV Community

Taylor Wang
Taylor Wang

Posted on

The Model Replied Twice Because My Client Gave Up First

The Model Replied Twice Because My Client Gave Up First

Last week my invoice-extraction service wrote the same invoice into the database twice, and the two rows were identical except for their timestamps. The webhook log said the customer submitted exactly one PDF, yet the model endpoint had been called twice from my server's IP. So where did the second request come from, and why did my code treat it as a brand-new success?

I run this service on MonkeyCode's free server tier with its free model access, which keeps the monthly bill at zero while I experiment. (Disclosure: This article was prepared as part of MonkeyCode's product outreach.) The architecture is deliberately boring: a webhook receives the PDF, a worker calls the model to extract the fields, and another query inserts the result into Postgres. Boring architectures are exactly where the strangest bugs hide, because nobody suspects the boring parts.

The symptom: two rows, one submission

The duplicate showed up in a routine check:

SELECT id, customer_id, invoice_number, amount, created_at
FROM invoices
WHERE customer_id = 'cust_1042'
ORDER BY created_at;
Enter fullscreen mode Exit fullscreen mode

The output had two rows with the same invoice_number, the same amount, and created_at values fourteen seconds apart. The customer had definitely not submitted twice, and the webhook provider's delivery log confirmed a single POST. My first instinct was to blame the free model endpoint for double-firing, so I spent an hour staring at the provider dashboard.

The wrong suspect

The dashboard showed two requests from my server's IP, which seemed to confirm my suspicion. Then I added request IDs to every log line, and the real story appeared:

14:02:11.004  req_9f31  webhook received, payload hash=7c2e...
14:02:11.102  req_9f31  calling model endpoint
14:02:21.003  req_9f31  client timeout after 10s, aborting
14:02:21.004  req_9f32  retry #1, same payload hash=7c2e...
14:02:21.105  req_9f32  calling model endpoint
14:02:24.310  req_9f32  model returned 200, writing invoice
14:02:25.901  req_9f31  model returned 200, writing invoice
Enter fullscreen mode Exit fullscreen mode

There it was. The first request did not fail; it was simply slow. My client gave up at ten seconds, the server kept processing in the background, and the model finally answered at fourteen seconds on a connection my client had already abandoned. My code then processed that orphaned response as a fresh success, and the second insert sailed through because nothing stopped it.

The model answered twice because my client gave up once. The retry was the bug, not the model.

Why my timeout was wrong

I had set the timeout to ten seconds because the endpoint usually answered in three. But "usually" is not a timeout, and the latency distribution was bimodal: warm workers answered fast, while cold starts on the free server pushed the p95 closer to fifteen seconds. My timeout was surgically cutting off the slow tail, and every cut produced a false failure.

The deeper mistake was treating a timeout as a failure. A timeout means "I do not know what happened," not "the request failed." The request may still be running, and the server may still write the result after my client walks away. If you retry an unknown outcome without an idempotency key, you get exactly what I got: two rows.

The fix, in three layers

1. Make the write idempotent at the database level

Code-level checks are nice, but the database is the only place where a duplicate actually hurts. I derived an idempotency key from the input, not from the model output, and added a unique constraint:

ALTER TABLE invoices
ADD CONSTRAINT invoices_idempotency_key UNIQUE (idempotency_key);
Enter fullscreen mode Exit fullscreen mode

The key is a hash of the PDF bytes plus the customer ID, so it stays stable even if the model returns a slightly different amount on a retry. The insert then becomes a safe no-op when the key already exists:

INSERT INTO invoices (idempotency_key, customer_id, invoice_number, amount)
VALUES (%s, %s, %s, %s)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
Enter fullscreen mode Exit fullscreen mode

If the second attempt arrives, the database quietly returns no row, and the first write wins.

2. Set the timeout from the p95, not the p50

I measured the real latency distribution over a week before choosing a number. The p95 on cold starts was around fifteen seconds, so the timeout went to thirty seconds, and the false aborts mostly disappeared. Measure the tail before you tune the timeout, and re-measure after you change anything about the server.

3. Retry with jittered exponential backoff, and only on safe outcomes

The retry loop now looks like this:

import random
from time import sleep

def call_model_with_retries(payload, max_attempts=3):
    for attempt in range(max_attempts):
        try:
            return client.chat(payload, timeout=30)
        except TimeoutError:
            # Unknown outcome: the server may still be processing.
            # Retrying is safe only because the write is idempotent.
            sleep(min(2 ** attempt, 8) + random.uniform(0, 1))
    raise RuntimeError("model call failed after retries")
Enter fullscreen mode Exit fullscreen mode

The comment is the point. Retrying is only safe because layer one guarantees that a duplicate write is a no-op. If the write were not idempotent, the correct move would be to query the result by idempotency key before retrying, not to blindly fire the request again.

The decision table I wish I had written earlier

What happened Client sees Safe to retry? Correct action
Request never reached the server Timeout / connection error Yes Retry with jittered backoff
Server processed, response lost Timeout Only with idempotency key Retry, then dedupe
Server processed, client aborted Timeout Only with idempotency key Retry, then dedupe
Server returned an error 4xx / 5xx Depends on the error Inspect before retrying

The middle two rows are the ones that bite people. A timeout is ambiguous by definition, and any retry strategy that ignores that ambiguity will eventually create a duplicate.

What I would do differently

  • Log time-to-first-byte separately from total time, because a slow first byte and a slow stream are different problems.
  • Put the idempotency key in every log line so all attempts of one logical request are traceable.
  • Test the retry path deliberately: kill the connection mid-request, then check whether the server-side work completed anyway.

Limitations and who should not use this

This approach assumes your write is naturally keyed by the input. If the model output is the thing you store and it can vary between attempts, the unique constraint still works, but you must decide which attempt wins. If you cannot tolerate any duplicate side effects — charging a credit card, sending an email, creating a support ticket — do not rely on retries plus ON CONFLICT DO NOTHING; use a transactional outbox or the provider's idempotency API instead. And if your latency budget is strict, a free server with cold starts is the wrong place for the hot path; this fix makes the system correct, not fast.

The model was never the problem. The problem was that I designed the retry logic as if "I did not hear back" meant "it did not happen." That assumption holds most of the time, until the day it does not, and on that day you get a duplicate invoice. If you want to avoid this class of bug entirely, add the idempotency key before you add the retry — the order matters.

Top comments (0)