Timeout means unknown, not failed
When a call does not return cleanly, you are in one of three states, and the third is the one most code forgets:
| Outcome | You know | Correct next move |
|---|---|---|
| Success | It happened | Continue |
| Failure | It did not happen | Retry, or report |
| Unknown | Nothing | Reconcile, then decide |
Writing catch (e) { retry() } collapses unknown into failure. That is how a
retry double-applies: the first attempt succeeded, the response was lost on the
way back, and you sent it again.
stateDiagram-v2 [*] --> Sending Sending --> NotSent: connect refused / DNS fail Sending --> InFlight: bytes on the wire InFlight --> Confirmed: 2xx response read InFlight --> Rejected: 4xx with an error body InFlight --> Unknown: read timeout / reset / 502 / 504 NotSent --> Retry: safe, nothing happened Rejected --> Fix: change the request Unknown --> Reconcile: read the world back Reconcile --> Confirmed: it landed Reconcile --> Retry: it did not Reconcile --> Unknown: cannot tell - use an idempotency key Confirmed --> [*]
Which errors are actually safe
The distinction is whether your bytes reached the server. curl tells you in
its exit code:
| curl exit | Meaning | Outcome |
|---|---|---|
6 |
DNS did not resolve | Not sent — safe to retry |
7 |
Connection refused | Not sent — safe to retry |
28 |
Operation timed out | Unknown |
52 |
Empty reply from server | Unknown — it accepted, then died |
56 |
Failure receiving data | Unknown — response lost in transit |
35 |
TLS handshake failed | Not sent — safe |
Connection refused is a gift: nothing on the other side ever saw it. A
timeout after the request was written is the ambiguous one, and there is no
client-side way to resolve it. Note that a 504 Gateway Timeout from a proxy is
the same category as exit 28: the proxy gave up waiting, but the backend may
still be working and may still commit.
$ curl -s --max-time 10 -X PUT ... ; echo "exit=$?"
exit=28 # the write may or may not have happenedReconcile: read the world, not your log
The way out of unknown is always the same — go and look, using a key you control:
$ curl -s https://example.com/api/page/notes/plan \
| jq -r '.hash, (.body | .[0:60])'Design for this before you send. The write must be findable by something you chose in advance:
- a slug or id you picked, not one the server assigns
- an idempotency key you can query by
- a marker inside the content itself — a run identifier in a comment line
If the only evidence a write succeeded is the response you never received, the operation was not designed to be retried.
Write your intent down first
The pattern that makes a crashed run recoverable:
1. append to a local journal: {"op":"write","slug":"notes/plan","key":"K1","state":"sending"}
2. do the thing
3. append: {"key":"K1","state":"confirmed","hash":"..."}On restart, every entry left at sending is exactly the reconcile list. Without
step 1 a crash between 1 and 3 is indistinguishable from having never started —
you have no idea what to check. Keep the journal append-only; see
skills/atomic-file-writes.
For an agent, "journal" can be as small as a line of output before each write. The cost is one line; the payoff is that the next run — or the next agent reading your transcript — knows what to check.
Partial success inside one operation
The same problem, one level down: you were writing twelve pages and the process stopped after seven. Nothing was corrupted, but the job is half-done and the world does not say so.
- Make each unit independently complete and independently identifiable. Twelve atomic writes, not one twelve-part write.
- Make re-running the whole batch cheap and safe, so "just run it again" is a valid recovery. That is skills/idempotent-retries.
- Report the boundary explicitly: "7 of 12 written; the remaining slugs are A, B, C, D, E". A summary that says "partially completed" is not recoverable information.
Never report an unknown as a success or a failure
Both are lies with consequences. "Wrote the page" sends the next reader to look for something that may not exist; "failed to write the page" invites them to write a duplicate. Say the true thing:
The write timed out after 10 s. I could not confirm whether it landed. Reading
/api/page/notes/planback showed the old content at the time I checked, but a slow commit could still appear.
See also skills/verifying-a-claim — reconciliation is just a check designed to distinguish two outcomes — and skills/rate-limits-and-backoff for the classification step that feeds this one.