{"slug": "timeout-is-not-failure-the-state-your-ai-agent-is-missing", "title": "Timeout Is Not Failure: The State Your AI Agent Is Missing", "summary": "An engineer from the project argues that AI agents must treat network timeouts as a distinct 'outcome_unknown' state rather than as failures or successes, to prevent duplicate operations in production. The proposed state machine includes a reconciliation loop that queries external systems for proof of effect, and uses intent fingerprints for idempotency when APIs lack native support.", "body_md": "When an AI agent's HTTP request or browser tool call times out, what does your system record?\n\nIf it records `failed`\n\n, the agent has a blind spot. A network timeout does not mean the operation failed on the remote server; it means the connection closed before the client received the answer. If the server processed the mutation, retrying the call blindly will create a duplicate artifact: a double payment, a duplicate ticket, a repeat email, or a redundant article.\n\nIf it records `succeeded`\n\n, it is hallucinating certainty.\n\nThe missing state is ** outcome_unknown**—a first-class operational status that halts automatic retries, records the unconfirmed mutation, and hands off execution to an explicit reconciliation loop.\n\nIn our previous post, we discussed why agents need [action receipts](https://dev.to/anasbuilds997/your-ai-agent-doesnt-need-more-memory-it-needs-receipts-1e3m) rather than purely semantic memory. Following valuable discussions with practitioners on distributed systems and memory boundaries, this article turns that concept into a concrete, testable state machine you can drop into any production agent framework.\n\nNot all exceptions are created equal:\n\n``` php\n[Intent Recorded]\n       |\n       v\n[Attempting Transport] ---> (DNS / Local Socket / Auth error) ---> [REJECTED / SAFE_TO_RETRY]\n       |\n  (Bytes sent)\n       |\n       v\n[Awaiting Response]   ---> (Connection Timeout / Drop / 504)  ---> [OUTCOME_UNKNOWN]\n```\n\nTreating post-send ambiguity as a failure is the root cause of automated duplicate storms.\n\nHere is the complete lifecycle of a guarded agent action:\n\n| State | Type | Description | Allowed Next Transitions |\n|---|---|---|---|\n`planned` |\nTransient | Intent recorded locally with safe payload fingerprint. |\n`submitted` , `rejected`\n|\n`submitted` |\nTransient | Bytes sent to remote endpoint; awaiting response. |\n`succeeded` , `rejected` , `outcome_unknown`\n|\n`outcome_unknown` |\nSuspended | Network dropped or timed out after submission. Retries blocked. |\n`reconciling` , `manual_review`\n|\n`reconciling` |\nActive | Querying external system for proof of effect. |\n`succeeded` , `safe_to_retry` , `manual_review`\n|\n`succeeded` |\nTerminal | External ID verified via response or readback. | None |\n`safe_to_retry` |\nTerminal | Absence of effect proven via authoritative readback. | None (new intent required) |\n`rejected` |\nTerminal | Server returned deterministic client error (4xx). | None |\n`manual_review` |\nTerminal | Absence/presence cannot be proven programmatically. | Human intervention |\n\nIn payment engineering, distributed consensus is achieved through at-least-once delivery paired with a server-side deduplication key (an **Idempotency Key**).\n\nWhen an external platform natively supports idempotency headers (such as `Idempotency-Key: <uuid>`\n\nin Stripe or GitHub GraphQL mutation keys), reconciliation is straightforward: if you time out, you re-send with the exact same key.\n\nHowever, the vast majority of web APIs, CRUD services, and browser-driven surfaces **do not support native idempotency keys**. In those environments, the caller must carry the burden:\n\n`outcome_unknown`\n\ntriggers, the agent queries the read API (or search endpoint) for resources created by the agent's account within a bounded timestamp window matching the intent fingerprint.\n\n``` python\nimport hashlib\nimport json\n\ndef compute_intent_fingerprint(method: str, path: str, payload: dict) -> str:\n    canonical = json.dumps(\n        {\"method\": method.upper(), \"path\": path, \"payload\": payload},\n        sort_keys=True,\n        separators=(\",\", \":\")\n    )\n    return f\"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}\"\n```\n\nA subtle trap in state machine design is **destructive in-place updates**.\n\nIf an action goes `submitted -> outcome_unknown -> reconciling -> succeeded`\n\n, and you simply overwrite the state to `succeeded`\n\n, you destroy the historical record that the action lived in an indeterminate state for hours.\n\nDuring post-mortems (or when auditing race conditions where another worker observed missing state during that window), knowing *how* an action reached success is as critical as the final state.\n\nA robust action receipt preserves the full transition trajectory:\n\n```\n{\n  \"operation_id\": \"20260818T190000Z-a1b2c3d4e5\",\n  \"operation\": \"articles.create\",\n  \"state\": \"succeeded\",\n  \"intent_fingerprint\": \"sha256:4d8a...\",\n  \"state_history\": [\n    { \"state\": \"planned\", \"recorded_at\": \"2026-08-18T19:00:00Z\" },\n    { \"state\": \"submitted\", \"recorded_at\": \"2026-08-18T19:00:01Z\" },\n    { \n      \"state\": \"outcome_unknown\", \n      \"recorded_at\": \"2026-08-18T19:00:31Z\",\n      \"error\": { \"code\": \"timeout\", \"message\": \"Gateway Timeout 504\" }\n    },\n    { \n      \"state\": \"reconciling\", \n      \"recorded_at\": \"2026-08-18T19:05:00Z\" \n    },\n    { \n      \"state\": \"succeeded\", \n      \"recorded_at\": \"2026-08-18T19:05:02Z\",\n      \"reconciliation\": {\n        \"evidence\": \"Readback from /api/articles matched title fingerprint\",\n        \"external_id\": 4407310\n      }\n    }\n  ]\n}\n```\n\nHere is a Python implementation of the guarded execution and reconciliation pattern:\n\n``` python\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nimport uuid\n\n@dataclass\nclass ActionReceipt:\n    operation_id: str\n    action: str\n    target: str\n    fingerprint: str\n    state: str = \"planned\"\n    external_id: str | None = None\n    state_history: list[dict] = field(default_factory=list)\n\n    def transition_to(self, new_state: str, **meta):\n        self.state = new_state\n        self.state_history.append({\n            \"state\": new_state,\n            \"recorded_at\": datetime.now(timezone.utc).isoformat(),\n            **meta\n        })\n\ndef execute_guarded_action(client, action: str, target: str, payload: dict) -> ActionReceipt:\n    receipt = ActionReceipt(\n        operation_id=f\"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}\",\n        action=action,\n        target=target,\n        fingerprint=compute_intent_fingerprint(\"POST\", target, payload)\n    )\n    receipt.transition_to(\"planned\")\n\n    # 1. Record intent persistently before touching network\n    persist_receipt(receipt)\n\n    receipt.transition_to(\"submitted\")\n    persist_receipt(receipt)\n\n    try:\n        response = client.post(target, json=payload, timeout=10.0)\n        receipt.external_id = response.json().get(\"id\")\n        receipt.transition_to(\"succeeded\", status_code=response.status_code)\n    except TimeoutError as exc:\n        # Crucial: DO NOT retry. Mark as ambiguous.\n        receipt.transition_to(\"outcome_unknown\", error=str(exc))\n    except Exception as exc:\n        receipt.transition_to(\"rejected\", error=str(exc))\n    finally:\n        persist_receipt(receipt)\n\n    return receipt\n\ndef reconcile_receipt(client, receipt: ActionReceipt, read_fn) -> ActionReceipt:\n    if receipt.state != \"outcome_unknown\":\n        return receipt\n\n    receipt.transition_to(\"reconciling\")\n    persist_receipt(receipt)\n\n    matched_item = read_fn(client, receipt.fingerprint)\n    if matched_item:\n        receipt.external_id = matched_item[\"id\"]\n        receipt.transition_to(\"succeeded\", evidence=\"Matched on readback query\")\n    else:\n        # If absence is authoritatively proven, mark safe for a fresh attempt\n        receipt.transition_to(\"safe_to_retry\", evidence=\"Authoritative readback showed 0 records\")\n\n    persist_receipt(receipt)\n    return receipt\n```\n\n`manual_review`\n\n.`DELETE`\n\n) are inherently trickier to reconcile because absence is the intended target state. A missing item could mean either the delete succeeded or the item never existed.When building autonomous agents interacting with external APIs or browser surfaces:\n\n**Which external write in your systems is hardest to reconcile after an unexpected timeout, and how do you prevent duplicate execution?**", "url": "https://wpnews.pro/news/timeout-is-not-failure-the-state-your-ai-agent-is-missing", "canonical_source": "https://dev.to/anasbuilds997/timeout-is-not-failure-the-state-your-ai-agent-is-missing-1fml", "published_at": "2026-08-18 19:56:57+00:00", "updated_at": "2026-08-18 20:13:40.595664+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "developer-tools"], "entities": ["Stripe", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/timeout-is-not-failure-the-state-your-ai-agent-is-missing", "markdown": "https://wpnews.pro/news/timeout-is-not-failure-the-state-your-ai-agent-is-missing.md", "text": "https://wpnews.pro/news/timeout-is-not-failure-the-state-your-ai-agent-is-missing.txt", "jsonld": "https://wpnews.pro/news/timeout-is-not-failure-the-state-your-ai-agent-is-missing.jsonld"}}