An AI agent retried a timed-out write and it ran twice: how to make retries safe

The agent's client gave up after half a second. Postgres finished the credit anyway, and the retry credited again. Here are the measured runs, the fix, and the case our own tool misses.

The short answer

When an AI agent’s database call times out, the write may still commit. The client stopped waiting; the server didn’t stop working. If the agent then retries, the write happens twice. The fix is an idempotency key: a unique id for the intended write, stored in the same transaction under a unique constraint, so a retry with the same key changes nothing. Server-side timeouts shrink the window but don’t close it.

Agents retry a lot. A tool call that returns nothing looks like a failure to a model, and “try again” is the obvious next step. For reads that’s harmless. For a credit, a refund, an email or an order, it’s a second one. It belongs on the checklist before an agent touches production.

Deedglass reads what Postgres actually committed, not what the agent reported. In our demo a hidden trigger turned 1 row into 301; a person countersigned the undo and all 301 rows came back.

The case we miss

We start with our own gap. In the open benchmark we run against our gateway, Deedglass, scenario 06 is exactly this: a slow credit times out and the agent retries, and both credits commit. Deedglass can’t check the slow statement, because it calls a function Deedglass can’t read, and nothing flags the double credit: each statement did what it said.2

That last clause is the hard part, and it applies to every tool that checks one statement at a time. Each attempt is a correct, one-row update. Compared one by one against what it asked for, each would pass, which is why a check of one action at a time can’t see it. The mistake only exists across the two, and only if you know they were meant to be one. The benchmark post has the other five cases we miss.

The scenario’s notes say what does stop it: send both attempts with the same idempotency key, and the gateway returns the first action instead of running the retry.1 The rest of this post is that idea, without Deedglass, in plain Postgres.

The run: one credit, two commits

A throwaway PostgreSQL 18.6 cluster with one account holding 500. A function stands in for anything that makes a statement slow, such as a lock wait or a heavy trigger:

Code A database command in SQL. The text around it says what it does.

CREATE TABLE accounts (id integer PRIMARY KEY, balance numeric NOT NULL);
INSERT INTO accounts VALUES (1, 500);
CREATE FUNCTION slow(ms integer) RETURNS boolean LANGUAGE plpgsql
  AS $$ BEGIN PERFORM pg_sleep(ms / 1000.0); RETURN true; END $$;

The agent’s write takes 1.5 seconds on the server:

Code A database command in SQL. The text around it says what it does.

UPDATE accounts SET balance = balance + 100 WHERE id = 1 AND slow(1500);

A small Python driver runs psql with a 0.5 second client timeout, kills it when the timeout passes (as an agent framework’s tool timeout would), waits, and retries once with a longer timeout. We ran it four ways on October 25, 2026, resetting the balance to 500 each time. Each cell is the driver’s output as printed:

Setup Attempt 1 Balance 1.5 s later Retry Final balance
Nothing extra no reply, client gave up after 0.50 s 600 UPDATE 1 after 1.53 s 700
SET client_connection_check_interval = '100ms' no reply, client gave up after 0.51 s 500 UPDATE 1 after 1.59 s 600
SET statement_timeout = '400ms' ERROR: canceling statement due to statement timeout after 0.43 s 500 same error 500
Idempotency key (below) no reply, client gave up after 0.51 s 600 UPDATE 0 after 0.04 s 600

The first row is the bug: the client saw nothing, the server committed 100, and the retry committed another 100. The agent would report “credited 100 once, after one retry”, which is what it believes. The account holds 700.

Why a timeout doesn’t mean it didn’t happen

A client-side timeout is the client giving up. Postgres doesn’t know until it next touches the connection: with connection checks off, which is the default, the server notices a lost client only at its next interaction with the socket, when it waits for, receives or sends data.4 A statement that’s busy inside a function isn’t touching the socket, so it runs to the end and, in autocommit, commits. The server’s attempt to send UPDATE 1 back is what fails, and by then the row is written.

Amazon’s Builders’ Library describes the same dilemma for any remote call: after a timeout the caller can’t tell whether the work happened, and the remedy it describes is a unique, caller-provided client request identifier.7 Databases are no different from APIs here.

Do server-side timeouts fix it?

They help, and both are worth setting for an agent’s role. Neither closes the gap.

statement_timeout aborts any statement that takes longer than the limit, measured from when the command arrives at the server until the server completes it.3 Set it below the agent’s client timeout and a slow write fails on the server, rolls back, and the client gets a real error instead of silence: the third row of the table. The agent knows the write didn’t happen. The catch is that the limit has to be shorter than every timeout above it, in the driver, the tool, the agent framework and the MCP client, and those are often not yours to set.

client_connection_check_interval makes the server poll the socket while a query runs, so it can abort sooner when the kernel reports the connection closed.4 In the second row the server dropped the connection mid-statement (its log says FATAL: connection to client lost), rolled back, and the retry was the only credit. It works on Linux, macOS, illumos and the BSDs only, and it depends on the kernel noticing the lost connection, which on a real network may need TCP keepalive tuning.4

Both still leave one window: the statement finishes and commits, and the reply is lost on the way back. The client sees a timeout for a write that committed. No timeout setting can tell the client what happened in that window. Only a record of the write, keyed by something the client chose, can.

The fix: a key written in the same transaction

Give every intended write a key before the first attempt, store the key in the same transaction as the write, and make the key unique. A retry reuses the key; if the first attempt committed, the retry’s insert finds the key and does nothing.

Code A database command in SQL. The text around it says what it does.

CREATE TABLE credits (
  request_id uuid PRIMARY KEY,
  account_id integer NOT NULL REFERENCES accounts (id),
  amount numeric NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now());

WITH ins AS (
  INSERT INTO credits (request_id, account_id, amount)
  VALUES ('5b0c6d2e-8f4a-4c1e-9a53-2f7d1e0b8c44', 1, 100)
  ON CONFLICT (request_id) DO NOTHING
  RETURNING account_id, amount)
UPDATE accounts a SET balance = a.balance + ins.amount
FROM ins WHERE a.id = ins.account_id AND slow(1500);

ON CONFLICT DO NOTHING skips the insert when the key exists, and RETURNING gives back only rows that were actually inserted or updated, so the update has nothing to join to.5 Same driver, same 0.5 second timeout. A retry 1.5 seconds later:

Code What the screen or file shows, copied as is.

balance before: 500
attempt 1: no reply, client gave up after 0.51s
balance after attempt 1: 600
attempt 2 (same request_id): UPDATE 0 after 0.04s
balance after retry: 600

And a retry sent the moment the client gave up, while attempt 1 was still running on the server:

Code What the screen or file shows, copied as is.

balance before: 500
attempt 1: no reply, client gave up after 0.51s
attempt 2 (same request_id): UPDATE 0 after 1.08s
balance after retry: 600

The second run is the important one. The retry waited about a second, because an insert into a table with a unique index can block on a concurrent session inserting the same value,5 then found the key committed and did nothing. One credit either way.

Three details make it work in practice:

  1. The key comes from the caller, before the first attempt. Generated by the agent’s tool layer per intended action, not per HTTP request and never by the database.
  2. The key and the effect commit together. A key written in a separate transaction can commit while the effect doesn’t, or the reverse.
  3. UPDATE 0 is an answer, not an error. On a retry, look the key up: if the row is there, report the first attempt’s result. Otherwise the agent sees “0 rows” and retries a third time.

Idempotency keys in APIs and agent tools

Payment APIs settled this long ago. Stripe’s API takes an Idempotency-Key header on POST requests; it suggests V4 UUIDs, accepts keys up to 255 characters, saves the status code and body of the first request once the endpoint starts executing, returns that saved response to retries (500 errors included), and errors if a retry’s parameters differ from the original.6 If your agent calls an API like that, the tool wrapper’s job is to create one key per intended action and reuse it on every retry.

Deedglass’s gateway does the same for agent tool calls. A key names one action per agent for 24 hours; the same key with the same request returns the original action and the tool runs once; a duplicate that arrives while the first is still running waits for it; and the same key with a different request is refused with 422 idempotency_conflict.8

Here is the limit, and it’s the reason scenario 06 is still a miss. Deedglass’s MCP wrapper creates a new key for each tool call the model makes, and reuses it only for its own retries to the gateway.9 When the model itself decides to call the tool again, that’s a new tool call with a new key, and the gateway runs it. No proxy can tell “retry of the same intent” from “a second, deliberate credit” if the model sends two identical calls; the key has to come from whoever knows the intent. For a write that must happen once, put the key in the tool’s arguments, for example a request_id the model is told to reuse, and store it in the database as above.

A checklist for agent retries

Check Why
Every write tool that moves money, sends, or creates takes a request_id argument the key has to come from the caller, before the first attempt
The key is stored with a unique constraint, in the same transaction as the effect so a retry finds it, and the two never commit apart
statement_timeout on the agent’s role is shorter than every client and tool timeout a slow write fails loudly instead of committing in silence
client_connection_check_interval is set where your platform supports it the server stops work for a client that left
The tool reports UPDATE 0 on a known key as “already done”, with the first result so the agent doesn’t retry again
Retries of non-idempotent writes are capped at one, or wait for a person a third attempt rarely helps
Your audit can group actions by key or intent, not only by statement a duplicate only shows across two records

Questions people ask

Does Postgres roll back a query when the client disconnects?

Not right away by default. With connection checks off, the server notices a lost client only at its next read or write on the socket, so a running statement can finish and commit.4 Setting client_connection_check_interval lets it abort sooner on supported platforms.

Is statement_timeout enough to prevent duplicate writes?

No. It makes slow statements fail on the server, which helps, but a statement that commits just before its reply is lost still looks like a timeout to the client. Only an idempotency key stored with the write tells a retry that the first attempt committed.

What is an idempotency key?

A unique id the caller attaches to one intended operation and reuses on every retry of it. The server remembers the key with the result, so a repeat returns the first result instead of running again. Stripe and other payment APIs use the Idempotency-Key header for this.6

Does INSERT ON CONFLICT DO NOTHING handle concurrent retries?

In the run above, yes: the retry blocked on the unique index until the first attempt committed, then inserted nothing. The docs say inserts into tables with unique indexes might block on concurrent sessions inserting the same values.5 Without the unique constraint, there’s nothing to conflict on.

Try it

The benchmark that holds scenario 06 is open: make corpus-bench in the Deedglass repository runs it in Docker, and you can plug in your own checker to see whether it catches the double credit we miss. The idempotency docs cover how the gateway handles keys. To check your own agent’s setup, run npx deedglass scan (what the grade means); the playground shows the write-versus-commit gap in a browser tab.

Written with AI assistance and checked against our claims register.

The takeaway

A client that gave up doesn’t mean the write failed: Postgres finished it, so give each write an idempotency key the retry can’t get past.

Read nextWe show where our gateway loses: an open benchmark for agent-database tools Try it: install in 7 s

Related on this site

More posts

Sources

  1. 1deedglass/deedglass: corpus scenario 06-timeout-unknown · github.com · checked 25 October 2026
  2. 2Corpus benchmark results (latest committed run) · github.com · checked 25 October 2026
  3. 3PostgreSQL docs: Client connection defaults (statement_timeout) · postgresql.org · checked 25 October 2026
  4. 4PostgreSQL docs: Connections and authentication (client_connection_check_interval) · postgresql.org · checked 25 October 2026
  5. 5PostgreSQL docs: INSERT (ON CONFLICT) · postgresql.org · checked 25 October 2026
  6. 6Stripe API reference: Idempotent requests · docs.stripe.com · checked 25 October 2026
  7. 7Amazon Builders' Library: Making retries safe with idempotent APIs (Malcolm Featonby) · aws.amazon.com · checked 25 October 2026
  8. 8Deedglass docs: Idempotency · docs.deedglass.com · checked 25 October 2026
  9. 9deedglass/deedglass: the MCP wrapper's call path (internal/mcp/wrap/call.go) · github.com · checked 25 October 2026

Published 25 October 2026, sources last checked 25 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded.

Pricing