DEV Community

Cover image for A Timeout Is Not a Retry Signal
Kent Bodrov
Kent Bodrov

Posted on

A Timeout Is Not a Retry Signal

A Timeout Is Not a Retry Signal

An agent asks a deployment provider to start a release. The provider accepts the request. The network connection dies before the response reaches the agent.

What happened?

The release may be running. It may have failed before it started. The provider may have accepted the request but delayed execution. From the caller's point of view, all three outcomes look the same: no trustworthy result arrived.

This is where a surprising number of "reliable" automation flows become duplicate-action machines. They translate missing evidence into failure, then retry the write.

The missing response is not a failure verdict. It is an uncertain external effect.

Idempotency is necessary, but it does not answer the question

An idempotency key prevents the same logical request from creating two effects, provided the whole path respects that key. It should be minted before dispatch and bound to the exact actor, target and payload.

That handles one problem:

If I submit this logical operation again, will the system create a second release?

It does not automatically answer another:

Did the first operation commit, and what state did it leave behind?

Those questions are often collapsed into one boolean. They should not be.

A local service can remember that it dispatched key deploy-42. The provider may still expose no lookup by that key. Or the provider may return an operation ID, but the caller may time out before persisting it. Or a process may restart while the local record still says deploying.

In all three cases, replaying the local operation record is not reconciliation. It only proves what the caller remembers.

Model the attempt and the outcome separately

I find it useful to keep two related records.

The attempt record describes the requested external action:

  • stable operation ID;
  • idempotency key;
  • actor and authorization reference;
  • target account, project or resource;
  • normalized payload hash;
  • provider route;
  • dispatch timestamp;
  • provider request ID, if one was received.

The outcome record describes what can actually be proved:

  • provider acknowledgement;
  • provider object ID;
  • current provider state;
  • independently observed result;
  • verification timestamp and source;
  • final classification;
  • recovery owner and deadline when unresolved.

This split stops an accepted HTTP request, a started command or an in-memory success flag from masquerading as the business outcome.

For a deployment, "the command started" is not the same as "the requested revision is serving healthy traffic." For a publication, "the submit button was clicked" is not the same as "the canonical post exists at this public URL." For a payment, "the client timed out" says nothing reliable about whether money moved.

Give uncertainty a durable state

The state machine needs a place where automation stops making new writes.

One practical sequence is:

prepared
  -> authorized
  -> dispatching
  -> acknowledged
  -> verifying
  -> succeeded | failed

dispatching | acknowledged | verifying
  -> unknown
  -> reconciling
  -> succeeded | failed | confirmed_absent | needs_human
Enter fullscreen mode Exit fullscreen mode

unknown should be persisted, not thrown as a transient exception. It should carry enough context for another process to continue the investigation without reconstructing intent from logs.

At minimum, store:

  • the exact request fingerprint;
  • the last confirmed transition;
  • which evidence is missing;
  • whether another write is forbidden;
  • who owns reconciliation;
  • when the next read should happen;
  • what would count as proof of commit or proof of absence.

This turns "something went wrong" into a bounded operational task.

Reconcile with reads before considering another write

A safe recovery path is deliberately boring.

  1. Freeze redispatch. Keep the original idempotency key and mark the operation as unresolved.
  2. Query the provider. Use a provider request ID, client reference, deterministic name or bounded time window.
  3. Match the intended effect. Compare target, payload hash, revision, account and timestamps. A vaguely similar object is not enough.
  4. Verify the customer-visible result. Check the declared postcondition through an independently meaningful read. Provider acknowledgement alone may be insufficient.
  5. Record the conclusion. Persist the provider object ID, evidence source and classification.
  6. Permit a new write only after confirmed absence. Use a new attempt identity while retaining the link to the unresolved attempt.

The difficult branch is confirmed_absent. Many provider APIs cannot prove a global negative. Pagination may be incomplete, draft inventory may be hidden, or the caller may lack permission to inspect all relevant objects.

When coverage is incomplete, the honest result is still unknown. Manual review is safer than manufacturing certainty.

A bug we found in our own deployment flow

In a bounded deployment dogfood project, we wrote two rules early:

  • repeating a deploy request with the same idempotency key must return the original operation rather than dispatch a second deployment;
  • missing or uncertain evidence must never become running or ready.

Those rules were useful, but a later assurance review exposed a gap. A crash after dispatch could strand an operation in deploying. Replaying the request returned the stale local record. The system still did not prove whether the external effect occurred.

That finding changed the question we ask during review. We no longer stop at "is the write idempotent?" We also ask "what authoritative read can settle an interrupted write, and who owns that reconciliation?"

The review did not prove that every provider needs the same recovery mechanism. It did show that idempotency without an evidence path can freeze ambiguity rather than resolve it.

Tests that reveal the real contract

Happy-path tests rarely expose this class of failure. Inject uncertainty at the boundaries:

  1. The provider commits the effect, then the client connection times out.
  2. The provider returns an operation ID, then the local process crashes before persisting it.
  3. The local record is persisted, but the provider rejects the request before commit.
  4. The provider reports success, but the customer-visible postcondition fails.
  5. Reconciliation finds two similar objects and cannot identify a unique match.
  6. Provider inventory is paginated or incomplete, so absence cannot be proved.
  7. A worker tries to redispatch while the original attempt is still unknown.

For each case, assert more than "no exception":

  • at most one external effect exists;
  • no unresolved operation triggers a blind retry;
  • the evidence record names what is known and what is missing;
  • success requires the declared postcondition;
  • manual escalation preserves the original operation identity.

The small design change with the largest effect

If an agent can mutate an external system, add unknown to its domain model before adding another retry policy.

Then define one authoritative read path for that state. It can be a provider operation lookup, a public-object search, a database reconciliation query or a manual inspection queue. The exact mechanism depends on the provider. The invariant stays the same: uncertainty blocks another write until evidence resolves it.

That makes the system less eager. It also makes it much easier to trust.


Practical exercise: pick one external write in your system and answer four questions: What is its stable operation identity? What proves the effect happened? What proves it did not happen? Who owns the operation when neither answer is available?

Top comments (2)

Collapse
 
autenai profile image
Auten •

The publication example is the one that bites hardest in UI-driven automation. When the write is a click, there is no request ID, no idempotency key and often no acknowledgement at all, so the whole attempt lives between dispatching and unknown.

One pattern that fits that case: take a cheap read of the postcondition surface right before the action (the sent-items list, the row count, the newest post URL) and store it in the attempt record. Reconciliation then becomes a diff against that snapshot instead of an attempt to prove a global negative, and confirmed_absent is only allowed when the snapshot and the re-read cover the same view.

How do you treat providers whose only acknowledgement is a later webhook? Does the operation sit in acknowledged, or in unknown until the webhook or a read arrives?

Collapse
 
reidmarlow profile image
Reid Marlow •

The other trap that turns confirmed_absent into duplicate writes is read-after-write consistency lag on the provider's query API.If an agent times out on a mutating POST and immediately queries the provider's list or search endpoint to check whether the object was created, that read often routes to a read replica or an eventually consistent search index. The query returns an empty list, the state machine marks the operation confirmed_absent, and dispatch sends a duplicate write. Five seconds later the replica catches up and you have two resources running.Unless the reconciliation read can hit an authoritative primary or you enforce an explicit grace window before declaring an object absent, treating a fast empty query as proof of non-existence just moves the race condition into the recovery worker.