{"slug": "a-version-check-won-t-recover-a-lost-response", "title": "A Version Check Won't Recover a Lost Response", "summary": "A developer published a Python/SQLite reference implementation showing how to make tool-call retries idempotent by storing a receipt keyed on workspace, principal, tool revision and request_id alongside each committed row change. The handler checks the receipt before the optimistic version condition, so a retry whose original response was lost recovers the committed result instead of failing a stale-version check, while current access checks still run first. The example ships with 12 tests and is explicitly a local demonstration, not an MCP transport or production code.", "body_md": "Both attempts used version 1. The first changed the row to version 2. Its response never reached the client.\n\nThe retry now fails the version check.\n\nThat is a useful refusal: the old request cannot overwrite another edit. But it doesn't answer the client's question: **did this operation succeed?** A conflict alone cannot identify which request produced the newer version.\n\n[The previous example](https://dev.to/smtasker-official/stop-stale-tool-calls-from-overwriting-newer-changes-1bjo) put an expected version inside the database write. This follow-up adds a saved result for one intended operation. It keeps the version check for new operations and recovers the original result for recognized retries.\n\nThe example is a local Python/SQLite handler with 12 tests. It does not implement an MCP transport or describe SMTasker's production code.\n\nConsider a hypothetical tool that changes an automation's interval:\n\n```\nclient saves request_id = edit-42\nclient sends: id=1, expected_version=1, interval_minutes=30\nserver commits: interval=30, version=2\nresponse is lost\nclient resends the same request_id and the same arguments\n```\n\nThe second attempt should recover the result of `edit-42`. It should not create a new intent, repeat the write, or quietly change `expected_version` to whatever the database contains now.\n\nAn expected version and a request identifier solve different problems:\n\n| Value | Question it answers | \n|---|---|\n| `expected_version` | Is this new edit based on the version the caller intended? | \n| `request_id` | Have we already committed this caller's intended operation? | \n\nNeither value grants permission. The handler still checks the trusted principal and the resource's workspace.\n\nThe handler stores a small receipt alongside the row change:\n\n``` php\n(workspace, principal, tool revision, request_id)\n    -> validated arguments + committed result\n```\n\nThe caller creates and persists the request ID **before the first send**. Retries reuse it. Two genuinely separate edits get different IDs, even if their arguments happen to match.\n\nThe service binds that key to a narrow, validated payload. Reusing a key with a different target, expected version or interval is an error. Sorting these three integer fields is sufficient for this example; it is not a general JSON normalization scheme.\n\nFor an authorized, accessible resource, the transaction does this:\n\nThe receipt lookup comes before the version condition. A successful original request has already advanced the version, so testing the old version first would reject the very retry we want to recognize.\n\nCurrent access checks come before the receipt lookup. Moving the record to another workspace or deleting it causes a refusal, even if an old receipt remains. This policy prioritizes current access over replay availability. A real service should choose and document its own equivalent policy.\n\nSave the following as `retry_receipt.py` and run:\n\n```\npython retry_receipt.py\n```\n\nIt requires Python 3.12+ and SQLite 3.35+ for `RETURNING`. It uses the standard library and temporary databases beneath a local `test-db` directory. The interval range is an illustrative domain rule, not a product limit.\n\n`Principal` represents trusted request context. Authentication code must supply the actual caller, workspace and current permission decision. This example does not validate tokens, resolve project membership, or implement revocation propagation.\n\n```\n\"\"\"Local SQLite demonstration, not an MCP server or a product implementation.\n\nRun with Python 3.12+ and SQLite 3.35+: python retry_receipt.py\nOnly temporary databases beneath this file's test-db directory are used.\n\"\"\"\nfrom concurrent.futures import ThreadPoolExecutor\nfrom contextlib import closing\nfrom dataclasses import dataclass, replace\nimport json\nfrom pathlib import Path\nimport re\nimport sqlite3\nimport sys\nimport tempfile\nfrom threading import Barrier\nimport unittest\n\nTOOL = \"set_interval.v1\"\n\n@dataclass(frozen=True)\nclass Principal:\n    # Created by trusted authentication/authorization code, never tool arguments.\n    user_id: str\n    workspace_id: str\n    scopes: frozenset[str]\n\n@dataclass(frozen=True)\nclass Reply:\n    result: dict\n    replayed: bool\n\nclass Refused(Exception):\n    pass\n\nclass KeyReuse(Exception):\n    pass\n\ndef connect(path):\n    db = sqlite3.connect(path, timeout=5, autocommit=True)\n    db.row_factory = sqlite3.Row\n    return db\n\ndef one(db, sql, params=()):\n    cursor = db.execute(sql, params)\n    try:\n        return cursor.fetchone()\n    finally:\n        cursor.close()\n\ndef set_interval(path, principal, request_id, automation_id,\n                 expected_version, interval_minutes):\n    if \"automation:write\" not in principal.scopes:\n        raise Refused(\"write not permitted\")\n    if not isinstance(request_id, str) or not re.fullmatch(\n            r\"[A-Za-z0-9_-]{1,64}\", request_id):\n        raise ValueError(\"request_id must be 1-64 letters, digits, _ or -\")\n    if any(type(v) is not int or v < 1\n           for v in (automation_id, expected_version)):\n        raise ValueError(\"id and version must be positive integers\")\n    if type(interval_minutes) is not int or not 5 <= interval_minutes <= 1440:\n        raise ValueError(\"illustrative interval range is 5-1440 minutes\")\n\n    # This narrow schema has three integer fields. No arbitrary-JSON hashing.\n    payload = json.dumps({\"id\": automation_id, \"version\": expected_version,\n                          \"interval\": interval_minutes},\n                         sort_keys=True, separators=(\",\", \":\"))\n    key = (principal.workspace_id, principal.user_id, TOOL, request_id)\n    with closing(connect(path)) as db:\n        try:\n            # SQLite serializes writers, including duplicate first attempts.\n            db.execute(\"BEGIN IMMEDIATE\")\n            # An old receipt does not grant current access to a resource.\n            if one(db, \"SELECT 1 FROM automations WHERE id=? AND workspace_id=?\",\n                   (automation_id, principal.workspace_id)) is None:\n                raise Refused(\"resource unavailable\")\n            old = one(db, \"\"\"SELECT payload, response FROM receipts\n                WHERE workspace_id=? AND user_id=? AND tool=? AND request_id=?\"\"\",\n                      key)\n            if old is not None:\n                if old[\"payload\"] != payload:\n                    raise KeyReuse(\"request_id reused with different intent\")\n                result = json.loads(old[\"response\"])\n                db.execute(\"COMMIT\")\n                return Reply(result, replayed=True)\n\n            row = one(db, \"\"\"UPDATE automations\n                SET interval_minutes=?, version=version+1\n                WHERE id=? AND workspace_id=? AND version=?\n                RETURNING id, interval_minutes, version\"\"\",\n                      (interval_minutes, automation_id,\n                       principal.workspace_id, expected_version))\n            if row is None:\n                raise Refused(\"version conflict; reload before a new intent\")\n            result = {\"id\": row[\"id\"], \"interval_minutes\": row[\"interval_minutes\"],\n                      \"version\": row[\"version\"]}\n            db.execute(\"INSERT INTO receipts VALUES(?,?,?,?,?,?)\",\n                       (*key, payload, json.dumps(result, sort_keys=True)))\n            # The row change and the receipt become visible together.\n            db.execute(\"COMMIT\")\n            return Reply(result, replayed=False)\n        except BaseException:\n            if db.in_transaction:\n                db.execute(\"ROLLBACK\")\n            raise\n\nSCHEMA = \"\"\"\nCREATE TABLE automations(\n    id INTEGER PRIMARY KEY, workspace_id TEXT NOT NULL,\n    interval_minutes INTEGER NOT NULL, version INTEGER NOT NULL);\nCREATE TABLE receipts(\n    workspace_id TEXT NOT NULL, user_id TEXT NOT NULL, tool TEXT NOT NULL,\n    request_id TEXT NOT NULL, payload TEXT NOT NULL, response TEXT NOT NULL,\n    PRIMARY KEY(workspace_id,user_id,tool,request_id));\nINSERT INTO automations VALUES(1,'team-a',15,1),(2,'team-a',15,1),(20,'team-b',15,1);\n\"\"\"\nALICE = Principal(\"alice\", \"team-a\", frozenset({\"automation:write\"}))\n\nclass ReceiptTests(unittest.TestCase):\n    def setUp(self):\n        base = Path(__file__).resolve().parent / \"test-db\"\n        base.mkdir(exist_ok=True)\n        self.temp = tempfile.TemporaryDirectory(prefix=\"receipt-\", dir=base)\n        assert Path(self.temp.name).resolve().is_relative_to(base.resolve())\n        self.addCleanup(self.temp.cleanup)\n        self.path = Path(self.temp.name) / \"demo.sqlite\"\n        with closing(connect(self.path)) as db:\n            db.executescript(SCHEMA)\n\n    def query(self, sql, params=()):\n        with closing(connect(self.path)) as db:\n            cursor = db.execute(sql, params)\n            try:\n                return [tuple(row) for row in cursor.fetchall()]\n            finally:\n                cursor.close()\n\n    def call(self, key=\"edit-42\", *, who=ALICE, target=1, version=1, minutes=30):\n        return set_interval(self.path, who, key, target, version, minutes)\n\n    def test_version_only_control_cannot_recover_its_own_result(self):\n        sql = \"\"\"UPDATE automations SET interval_minutes=30,version=version+1\n            WHERE id=1 AND workspace_id='team-a' AND version=1 RETURNING version\"\"\"\n        self.assertEqual(self.query(sql), [(2,)])\n        self.assertEqual(self.query(sql), [])  # same retry now conflicts\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(0,)])\n\n    def test_commit_then_lost_response_replays_from_fresh_connection(self):\n        def transport():\n            self.call()  # handler has committed and closed its connection\n            raise TimeoutError(\"simulated response loss after commit\")\n        with self.assertRaises(TimeoutError):\n            transport()\n        reply = self.call()\n        self.assertTrue(reply.replayed)\n        self.assertEqual(reply.result, {\"id\": 1, \"interval_minutes\": 30, \"version\": 2})\n        self.assertEqual(self.query(\"SELECT interval_minutes,version FROM automations WHERE id=1\"), [(30, 2)])\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(1,)])\n\n    def test_replay_returns_historical_result_without_undoing_newer_edit(self):\n        original = self.call()\n        later = self.call(\"edit-43\", version=2, minutes=60)\n        retry = self.call()\n        self.assertEqual(retry.result, original.result)\n        self.assertTrue(retry.replayed)\n        self.assertEqual(later.result[\"version\"], 3)\n        self.assertEqual(self.query(\"SELECT interval_minutes,version FROM automations WHERE id=1\"), [(60, 3)])\n\n    def test_same_key_cannot_change_target_version_or_value(self):\n        self.call()\n        for changes in ({\"minutes\": 60}, {\"version\": 2}, {\"target\": 2}):\n            with self.subTest(changes=changes), self.assertRaises(KeyReuse):\n                self.call(**changes)\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(1,)])\n        self.assertEqual(self.query(\"SELECT interval_minutes,version FROM automations WHERE id=2\"), [(15, 1)])\n\n    def test_new_key_does_not_bypass_old_version(self):\n        self.call()\n        with self.assertRaises(Refused):\n            self.call(\"another-key\", minutes=60)\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(1,)])\n\n    def test_same_text_key_is_scoped_to_principal_and_workspace(self):\n        self.call()\n        bob = replace(ALICE, user_id=\"bob\")\n        other_team = replace(ALICE, workspace_id=\"team-b\")\n        self.assertFalse(self.call(who=bob, version=2, minutes=60).replayed)\n        self.assertFalse(self.call(who=other_team, target=20).replayed)\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(3,)])\n\n    def test_current_scope_is_checked_even_for_a_saved_receipt(self):\n        self.call()\n        revoked = replace(ALICE, scopes=frozenset())\n        with self.assertRaises(Refused):\n            self.call(who=revoked)\n        self.assertEqual(self.query(\"SELECT version FROM automations WHERE id=1\"), [(2,)])\n\n    def test_moved_or_deleted_resource_does_not_replay_or_resurrect(self):\n        self.call()\n        self.query(\"UPDATE automations SET workspace_id='team-b' WHERE id=1\")\n        with self.assertRaises(Refused):\n            self.call()\n        self.query(\"DELETE FROM automations WHERE id=1\")\n        with self.assertRaises(Refused):\n            self.call()\n        self.assertEqual(self.query(\"SELECT count(*) FROM automations WHERE id=1\"), [(0,)])\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(1,)])\n\n    def test_receipt_failure_rolls_back_the_row_change(self):\n        self.query(\"\"\"CREATE TRIGGER fail_receipt BEFORE INSERT ON receipts\n            BEGIN SELECT RAISE(ABORT,'receipt store unavailable'); END\"\"\")\n        with self.assertRaises(sqlite3.IntegrityError):\n            self.call()\n        self.assertEqual(self.query(\"SELECT interval_minutes,version FROM automations WHERE id=1\"), [(15, 1)])\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(0,)])\n        self.query(\"DROP TRIGGER fail_receipt\")\n        self.assertFalse(self.call().replayed)\n\n    def test_simultaneous_duplicate_calls_share_one_result(self):\n        start = Barrier(2)\n        def submit():\n            start.wait(timeout=5)\n            return self.call()\n        with ThreadPoolExecutor(max_workers=2) as pool:\n            futures = [pool.submit(submit) for _ in range(2)]\n            replies = [f.result(timeout=10) for f in futures]\n        self.assertEqual(replies[0].result, replies[1].result)\n        self.assertEqual(sorted(r.replayed for r in replies), [False, True])\n        self.assertEqual(self.query(\"SELECT version FROM automations WHERE id=1\"), [(2,)])\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(1,)])\n\n    def test_simultaneous_key_mismatch_has_one_winner(self):\n        start = Barrier(2)\n        def submit(minutes):\n            start.wait(timeout=5)\n            try:\n                return self.call(minutes=minutes)\n            except KeyReuse:\n                return \"mismatch\"\n        with ThreadPoolExecutor(max_workers=2) as pool:\n            futures = [pool.submit(submit, m) for m in (30, 60)]\n            outcomes = [f.result(timeout=10) for f in futures]\n        self.assertEqual(sum(isinstance(x, Reply) for x in outcomes), 1)\n        self.assertEqual(outcomes.count(\"mismatch\"), 1)\n        winner = next(x for x in outcomes if isinstance(x, Reply))\n        self.assertEqual(self.query(\"SELECT interval_minutes,version FROM automations WHERE id=1\"), [(winner.result[\"interval_minutes\"], 2)])\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(1,)])\n\n    def test_invalid_or_inaccessible_requests_do_not_write(self):\n        for changes in ({\"target\": True}, {\"version\": 0}, {\"minutes\": 1},\n                        {\"minutes\": True}, {\"target\": \"1 OR 1=1\"}):\n            with self.subTest(changes=changes), self.assertRaises(ValueError):\n                self.call(**changes)\n        for key in (\"\", \"x\" * 65, None, \"unsafe key\"):\n            with self.subTest(key=key), self.assertRaises(ValueError):\n                self.call(key)\n        for target in (20, 999):\n            with self.subTest(target=target), self.assertRaises(Refused):\n                self.call(target=target)\n        self.assertEqual(self.query(\"SELECT count(*) FROM receipts\"), [(0,)])\n        self.assertEqual(self.query(\"SELECT version FROM automations ORDER BY id\"), [(1,), (1,), (1,)])\n\nif __name__ == \"__main__\":\n    print(f\"Python {sys.version.split()[0]} / SQLite {sqlite3.sqlite_version}\", flush=True)\n    unittest.main(verbosity=2)\n```\n\nLocal run: Python 3.12.14, SQLite 3.53.1; all 12 tests passed. These are local tests, not a live MCP interoperability or power-loss durability test.\n\nThe first test is the control: a version-scoped update succeeds once and conflicts on its own retry. There is no saved result to recover.\n\nThe lost-response test calls the real handler, lets it commit and close its connection, then raises a simulated transport timeout. Retrying through a new connection returns the saved version-2 result. The row stays at version 2 and there is one receipt.\n\nThe later-edit test is just as important:\n\n```\nedit-42 commits interval=30, version=2\nedit-43 commits interval=60, version=3\nretry edit-42 returns its original interval=30, version=2 result\nthe database remains interval=60, version=3\n```\n\nThat replay result is **historical evidence about an operation**, not a fresh resource snapshot. The `replayed` flag makes the distinction visible. A client must not use the old result to replace newer displayed state without its own version check or a fresh read.\n\nTwo barrier-based tests release requests from separate threads; each handler opens its own SQLite connection. Identical requests yield one new result and one replay. Different payloads under the same scoped key yield one accepted request and one mismatch. Which payload wins is deliberately unspecified.\n\nThe rollback test installs a trigger that rejects receipt insertion after the update has executed. The handler rolls the transaction back: no row change and no receipt remain. This tests the failure between the two writes, rather than assuming that a successful happy path proves atomicity.\n\nThe remaining cases cover key scope, rejected input, a new key with a stale version, removed write scope, and moved or deleted resources. The scope test supplies newly restricted trusted context; it does not prove that a production identity system detects revocation.\n\nSQLite permits one writer at a time. `BEGIN IMMEDIATE` obtains the write transaction before the lookup, so competing first attempts cannot both pass an unprotected lookup. That is a small-database implementation choice, not a high-throughput design recommendation. Keep this transaction short and handle lock contention explicitly in the surrounding service.\n\nThere is no external API call inside the transaction. Saving a receipt and sending an email, publishing a post or charging a card are not automatically one atomic operation. Those workflows need a recoverable handoff and a downstream deduplication or reconciliation strategy. This example makes no end-to-end exactly-once claim.\n\nThere is also no receipt expiry policy. Deleting receipts removes the ability to recover their results. In this versioned example, an old version normally still prevents a repeated write, but that is not a substitute for a replay contract. Define retention, backup/restore behavior, stable identities and what clients should do after the supported retry window. Reusing a deleted record ID or resetting versions needs additional protection.\n\nThe client has work to do too: persist the operation ID, preserve the original arguments, distinguish a retry from a new user decision, and avoid an unbounded retry loop. A request ID regenerated after every timeout defeats the contract.\n\n`BEGIN IMMEDIATE` and contention. Which lost response would be hardest to reconcile in your tool integration: changing a setting, starting a job, or calling an external service? Leave the failure sequence in the comments, including what evidence the client still has after the timeout.", "url": "https://wpnews.pro/news/a-version-check-won-t-recover-a-lost-response", "canonical_source": "https://dev.to/smtasker-official/a-version-check-wont-recover-a-lost-response-3npc", "published_at": "2026-11-06 19:17:23+00:00", "updated_at": "2026-11-06 19:23:33.916346+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "mlops"], "entities": ["SMTasker", "SQLite", "Python", "MCP"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-version-check-won-t-recover-a-lost-response", "markdown": "https://wpnews.pro/news/a-version-check-won-t-recover-a-lost-response.md", "text": "https://wpnews.pro/news/a-version-check-won-t-recover-a-lost-response.txt", "jsonld": "https://wpnews.pro/news/a-version-check-won-t-recover-a-lost-response.jsonld"}}