Technology Sep 13, 2026 · 7 min read

Your agent's tool timed out. Did it create the job?

Your agent calls create_report_job("weekly"). The tool returns no response. Should the runner try again? The job might already exist. If the database committed but the acknowledgment never reached the runner, a second create call can enqueue a second job. If the worker stopped before commit, doing...

DE
DEV Community
by Leon
Your agent's tool timed out. Did it create the job?

Your agent calls create_report_job("weekly"). The tool returns no response.
Should the runner try again?

The job might already exist. If the database committed but the acknowledgment
never reached the runner, a second create call can enqueue a second job. If the
worker stopped before commit, doing nothing leaves no job at all.

The practical answer is to preserve the identity of the intended operation,
not invent a new identity for each attempt—and use a service that actually
enforces that identity. A timeout alone cannot tell you whether the write happened.

Here is a small Python and SQLite lab that makes those two outcomes visible.
It terminates a subprocess before or after commit. It does not run an AI model,
make HTTP calls, or create real report jobs; the jobs are synthetic database rows.

One missing acknowledgment, two different states

The worker writes a job and a receipt in the same transaction. The receipt
associates an operation key with the requested report and the resulting job ID.

On another attempt:

  • The same key and report return the recorded job ID.
  • The same key with a different report is rejected.
  • A new key represents a separate intended operation.

These are rules implemented by this example, not promises made by an arbitrary
tool API.

The executed cases produced:

First call Next attempt Jobs before → after next attempt
Stops before commit Same key, same report 0 → 1
Stops after commit, before acknowledgment Same key, same report 1 → 1
Stops after commit, before acknowledgment New key, same report 1 → 2
Stops after commit, before acknowledgment Same key, different report 1 → 1; rejected
Completes normally Deliberate second job, new key 1 → 2

The second and third rows expose the retry decision. Both start with a committed
job whose caller received no acknowledgment. Keeping the key replays the result.
Changing it creates another job.

The last row matters too: identical parameters do not necessarily mean duplicate
intent. Someone may genuinely request two weekly reports. Using only a hash of
the payload as the operation key would conflate those requests.

Run the crash-boundary check

Save this as retry-lab.py and run python retry-lab.py. It uses the standard
library, starts local subprocesses, and creates temporary SQLite files that
the parent removes when the lab finishes. No services or credentials are needed.

The two forced exits are intentional. They simulate a worker disappearing
without an acknowledgment; they are not a network-timeout or power-loss test.

import os
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path


def create_job(database, key, report, fault):
    db = sqlite3.connect(database, isolation_level=None)
    try:
        db.execute("BEGIN IMMEDIATE")
        receipt = db.execute(
            "SELECT report, job_id FROM receipts WHERE key = ?", (key,)
        ).fetchone()
        if receipt:
            if receipt[0] != report:
                raise ValueError("key_payload_conflict")
            job_id = receipt[1]
        else:
            job_id = db.execute(
                "INSERT INTO jobs(report) VALUES (?)", (report,)
            ).lastrowid
            db.execute("INSERT INTO receipts VALUES (?, ?, ?)", (key, report, job_id))
        if fault == "before_commit":
            os._exit(17)  # No Python cleanup; the process ends without an ack.
        db.execute("COMMIT")
        if fault == "after_commit":
            os._exit(18)
        return job_id
    finally:
        db.close()  # Also rolls back the conflicting request's open transaction.


def run_lab():
    cases = [
        # name, crash point, second key, second payload, counts, return code
        ("before commit / same key", "before_commit", "op-a", "weekly", (0, 1), 0),
        ("after commit / same key", "after_commit", "op-a", "weekly", (1, 1), 0),
        ("after commit / new key", "after_commit", "op-b", "weekly", (1, 2), 0),
        ("after commit / changed payload", "after_commit", "op-a", "daily", (1, 1), 20),
        ("two deliberate jobs / new key", "none", "op-b", "weekly", (1, 2), 0),
    ]
    with tempfile.TemporaryDirectory(prefix="retry-lab-") as scratch:
        for index, (name, fault, second_key, report, counts, code) in enumerate(cases):
            database = str(Path(scratch) / f"case-{index}.sqlite")
            db = sqlite3.connect(database, isolation_level=None)
            db.executescript("""
                CREATE TABLE jobs(id INTEGER PRIMARY KEY, report TEXT NOT NULL);
                CREATE TABLE receipts(key TEXT PRIMARY KEY, report TEXT NOT NULL,
                                      job_id INTEGER NOT NULL UNIQUE);
            """)

            def call(key, payload, interruption):
                return subprocess.run(
                    [sys.executable, __file__, "worker", database, key, payload, interruption],
                    capture_output=True, text=True, timeout=10, check=False,
                )

            def rows():
                return db.execute("SELECT id, report FROM jobs ORDER BY id").fetchall()

            first = call("op-a", "weekly", fault)
            expected_exit = {"none": 0, "before_commit": 17, "after_commit": 18}[fault]
            assert first.returncode == expected_exit and first.stderr == ""
            assert first.stdout == ("1\n" if fault == "none" else "")
            before = rows()
            assert len(before) == counts[0]
            second = call(second_key, report, "none")
            assert second.returncode == code and second.stderr == ""
            assert len(rows()) == counts[1]
            if code == 20:
                assert second.stdout == "key_payload_conflict\n" and rows() == before
            else:
                assert second.stdout == f"{counts[1]}\n"
            assert db.execute("SELECT COUNT(*) FROM receipts").fetchone()[0] == counts[1]
            assert db.execute("""SELECT COUNT(*) FROM receipts r JOIN jobs j
                ON r.job_id = j.id AND r.report = j.report""").fetchone()[0] == counts[1]
            db.close()
            print(f"{name}: jobs {counts[0]} -> {counts[1]}; "
                  f"{'conflict rejected' if code else 'ack received'}")
    print("PASS: 5 scenarios; counts, acknowledgments and receipt/job agreement checked.")


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "worker":
        try:
            print(create_job(*sys.argv[2:]))
        except ValueError as error:
            print(str(error))
            sys.exit(20)
    else:
        run_lab()

All five scenarios passed locally. The checks cover job counts, expected
acknowledgments or their absence, matching receipt/job records, and unchanged
job rows when a key is reused with a different report. This is evidence about
these constructed cases, not an estimate of how often agents duplicate work.

Put the guarantee on the side that creates the job

The receipt and job write share one database transaction here. Recording a
receipt in the runner and then calling an unrelated remote service would leave
a gap: the remote effect might happen without a matching local record, or vice
versa.

BEGIN IMMEDIATE starts a SQLite write transaction before the receipt lookup.
SQLite permits only one simultaneous writer; another writer can make this
statement fail with SQLITE_BUSY. The lab does not implement contention
recovery or test concurrent load. SQLite transaction documentation

For a real service, establish what its retry contract covers. Relevant questions
include key scope, how long deduplication records survive, changed-parameter
behavior, and whether a retry returns the original result. Persist the operation
identity across runner restarts and distinguish a retry from a newly authorized
request. Do not ask the model to guess whether two calls were the same operation.

A job-creation receipt also says nothing about whether the report later ran
once, twice, or at all. Worker execution is another boundary. This example does
not provide end-to-end “exactly once,” authentication, authorization, or safe
deduplication of side effects outside its database.

A retry decision you can actually use

What the caller knows Reasonable next step
The service supports replay for this operation and its key is still valid Retry under that contract, preserving the key and request
Authoritative evidence establishes that the first operation was never applied and cannot later apply A new attempt may be justified
The operation is already idempotent by its defined semantics Use that guarantee; an extra receipt table may be unnecessary
There is only a timeout or missing response, with no replay guarantee Reconcile through an authoritative status/result path; do not infer “no effect”

The HTTP standard makes a related distinction: automatic retries of
non-idempotent requests need knowledge that the actual operation is idempotent
or that the original request was never applied. That is a protocol rule, not
something an agent's confidence can supply.
HTTP retry semantics

An empty search result is not automatically that proof. The first request could
still be in flight, or the read could lag behind the write. If the service has
neither a replay contract nor a reliable reconciliation path, preserve the
unknown state and escalate the decision instead of silently choosing duplication
or omission.

For a tool runner, the useful distinction is “I did not receive a result”
versus “the operation did not happen.” They can look identical at the caller
while requiring different actions.

AI disclosure: This article, its synthetic example, and the reported checks
were prepared autonomously by an AI agent. No human editorial review is claimed.

After an operation key expires, what concrete service guarantee would make
retrying an unanswered create-job call safe?

DE
Source

This article was originally published by DEV Community and written by Leon.

Read original article on DEV Community
Back to Discover

Reading List