Skip to content

Retry 409 write.contention in the v1 and v2 clients (aito-core #1619) - #87

Merged
arauhala merged 2 commits into
masterfrom
fix/retry-write-contention
Oct 8, 2026
Merged

arauhala merged 2 commits into
masterfrom
fix/retry-write-contention

Conversation

@arauhala

@arauhala arauhala commented Oct 8, 2026

Copy link
Copy Markdown
Member

td-20261008104251322745. Since aito-core #1619, a large write starved by concurrent same-table writes answers 409 write.contention with Retry-After: 1, where it used to answer 500. Nothing was committed, so repeating it is safe. Both clients now do.

The policy

It lives in one module, aito/_write_contention.py, used by both clients:

  • Keyed on the code (v1 body error, v2 data.code), never on 409 alone. The server's other 409s are raised at once, as before: an env being migrated, an old binary format that needs a repair, a release or branch conflict, or a 409 with no code. 429/500/503 aren't retried as contention either, even with a Retry-After.
  • Delay: the Retry-After floor (seconds; 1 when absent or unreadable), plus full jitter up to 0.5·2ⁿ s, capped at 8 s. Clients starved by the same churn don't come back in step.
  • Capped: write_contention_retries=3 by default, on either client. 0 turns it off; a negative value is refused. The final error keeps its code and status and adds "(retried N times)".

Where

  • v2 AitoClientV2.request: under every v2 method.
  • v1 AitoClient.request and async_request:
    • for the async path, aiohttp's raise_for_status moves after the check, so the body can be read first;
    • the sync path parses the body for the check only on a 409, so normal responses are still parsed once.

Tests (fail-first)

  • tests/sdk/test_write_contention_retry.py (v2, 10 tests) and tests/sdk/test_v1_write_contention_retry.py (v1, 6 sync + 3 async). They run against a fake server: real requests.Response objects for v1, an aiohttp stand-in for async. Sleeps are recorded and the jitter pinned.
  • The offline SDK suite matches master's baseline. The import aito.v2 weight tests still pass; the new module is standard library only.

Changelog: under Unreleased. It needs an engine newer than v2.11.4; older engines still answer this condition with a 500, which isn't retried.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AGxAujqUXtXMNcLfpqm96r

araufuturice and others added 2 commits October 8, 2026 22:22
…e code

A large write starved by concurrent same-table writes gives up without committing and, since
core #1619, answers 409 with the code write.contention and Retry-After: 1. Nothing was written,
so the request is safe to repeat. AitoClientV2.request (under every v2 method) now repeats it:

- only on the code write.contention: the server's other 409s (an env being migrated, an old
  binary format that needs a repair, a release or branch conflict) and any status without the
  code are raised at once, as before;
- after the Retry-After floor (seconds; 1 when absent or unreadable) plus full jitter up to
  0.5 * 2**n s, capped at 8 s, so clients starved by the same churn don't retry in step;
- at most write_contention_retries times (default 3; 0 turns it off; negative is refused).
  The final error keeps its code and status, and says how many times it was retried.

Fail-first: tests/sdk/test_write_contention_retry.py (10 tests against a fake server; sleeps
recorded, jitter pinned). FakeResponse grows a headers field.

td-20261008104251322745

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGxAujqUXtXMNcLfpqm96r
…and v2

Most traffic is still on v1, so a large v1 delete under same-table churn is the likelier real
case (CPO). aito/_write_contention.py now holds the one policy both clients use: the code
(v1 body `error`, v2 `data.code`), the Retry-After floor plus full jitter up to 0.5 * 2**n s
(cap 8 s), and the validated retry count. v2's request() uses it (no behaviour change).

v1 AitoClient(write_contention_retries=3):
- request(): a 409 whose code is write.contention is sent again, up to the cap; the body is
  parsed for that check only on a 409, so a normal response is still parsed once;
- async_request(): the same, with asyncio.sleep; aiohttp's raise_for_status moves after the
  check, so the body can be read first;
- RequestError(..., retries) says "(retried N times)" when it gave up after retrying.
Other 409s, 500/503, and raise_for_status=False behave as before.

Fail-first: tests/sdk/test_v1_write_contention_retry.py (6 sync on real requests.Response
objects, 3 async on an aiohttp stand-in). The v2 tests pin jitter through the shared module.

td-20261008104251322745

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGxAujqUXtXMNcLfpqm96r
@arauhala
arauhala merged commit 6d56965 into master Oct 8, 2026
16 checks passed
@arauhala arauhala mentioned this pull request Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants