Repository navigation
fix(host-cloudflare): a short D1 outage no longer fails every tool call - #23
Merged
Merged
Conversation
Wrap the D1 binding once in createD1ExecutorDb so every query (drizzle, schema fingerprint, data migrations) rides out a short D1 blip instead of failing the tool call. Only failures Cloudflare documents as transient are retried, up to five attempts with jittered exponential backoff (200ms base, 2s cap, about 3.6s worst case). A failure that means the database was never reached is retried for any statement. A failure that may have happened after the statement ran (for example "D1_ERROR: internal error") is retried only for read-only statements, so a write is never applied twice. SQL errors, constraint violations, limits and query-caused resets are never retried. Claude-Session: https://claude.ai/code/session_01UBfgbNPCqMRR2hryXb1FDL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Impact
A short D1 outage no longer fails every tool call that reads from the database. The Cloudflare host now waits and tries again for up to about 3.6 seconds before it returns an error.
Incident
On 2026-10-16, from 16:45:38 to about 16:48 UTC,
executor-cloudflarelogged 63D1_ERROR: internal error; reference = ...errors (9, 24, 22 and 8 per minute). Then D1 recovered by itself. During those ~3 minutes, every tool call that used D1 failed (machine_run_status,machine_exec,machine_open,github/search). One of these failures stopped an unattended PR-review run.What changed
apps/host-cloudflare/src/db/d1-retry.ts:withD1TransientRetry(db)wraps the D1 binding. It coversprepare().bind().first/all/run/raw,batch(it gives the binding its own statements),execandwithSession.createD1ExecutorDbwraps the binding once, at the top. All D1 traffic in this host goes through that function: drizzle runtime queries, the schema fingerprint, and the data migrations. Both callers (app.tsand the MCP session Durable Object) get the retry, and no call site changes.What is retried, and what is not
The classification matches the documented D1 error list (https://developers.cloudflare.com/d1/observability/debug-d1/). The wrapper reads the error message and its
causechain.Cannot resolve D1 DB due to transient issue on remote node,Internal error while starting up D1 DB storage caused object to be reset,D1 DB is overloaded. Too many requests queuedD1_ERROR: internal error(the incident),Network connection lost,Replica disconnected from primary,Internal error in D1 DB storage caused object to be reset,D1 DB reset because its code was updated,Can't read from request stream because client disconnected,D1 DB is overloaded. Requests queued for too longno such table,D1_TYPE_ERROR, size and free-tier limits, and the resets that the query itself causes (storage timeout, isolate memory, CPU limit)Writes. When a write fails with a "maybe executed" error such as
internal error, the write may or may not have been applied. The binding cannot tell which. So the wrapper retries a write only when the error shows that D1 was never reached. A write that fails withinternal errorreturns the original error, as before. A statement counts as read-only only if it starts withSELECT,WITHorEXPLAINand contains no keyword that can write. This is the same rule D1 uses for its own automatic retries. A batch is retried after a "maybe executed" error only if all of its statements are read-only.Timeouts. The brief listed timeouts. The only documented D1 timeout (
storage operation exceeded timeout which caused object to be reset) is caused by a write that is too large, so a retry fails again. It is not retried.Overload. The
D1 DB is overloadederrors are retried, but the attempts are few and spaced with jitter, so the extra load stays small. Cloudflare's advice for overload is to reduce the load.Backoff
Schedule.exponential(200ms), with each delay capped at 2s, thenSchedule.jittered(80% to 120%), withSchedule.recurs(4). This gives at most 5 attempts. The delays are about 200, 400, 800 and 1600 ms, so the worst case is about 3.6 s before the original error is returned. Thewhilepredicate stops at once on a failure that cannot be retried. Each retry logsEffect.logWarning("D1 transient failure, retrying")withd1.operation,d1.read_only,d1.attempt,d1.max_attempts,d1.failure_classandd1.failure.Tests
apps/host-cloudflare/src/db/d1-retry.test.ts(Effect Vitest, TestClock):causechain and a non-Error rejectioninternal erroris not retried (1 call)apps/host-cloudflare/src/db/d1.test.ts:createD1ExecutorDbgets through oneD1_ERROR: internal erroron the fingerprint read. This test fails if the wrapper is removed.I checked the tests by mutation. Each of these changes made at least one test fail: removing the attempt bound, retrying writes after "maybe executed" errors, dropping the "not executed" class, passing the wrapped statements to
batch, and removing the backoff delay.Gates
vitest run src/db(host-cloudflare): 49 passed. Full host-cloudflarebun run test: 136 passed.bun run typecheck: passed (45/45 tasks).bun run format:check: fails on 3 files that this PR does not change:packages/core/execution/src/index.ts,jev-discovery.test.tsandjev.test.ts.bun run lint: 0 findings in the changed files. It fails on 2 errors that this PR does not touch:executor(no-unknown-error-message)atpackages/core/execution/src/jev-discovery.ts:174:26andpackages/core/sdk/src/executor.ts:4240:24.bun run test(turbo,--continue): 38 of 39 tasks pass. The one failure was already there before this PR, in@executor-js/cloudflare, which this PR does not change:src/mcp/agents-post-stream-loss.test.ts > covers the sandbox execution ceiling plus the browser-approval wait plus marginwithexpected 660000 to be greater than or equal to 3901000(raiseMCP_POST_RESPONSE_DEADLINE_MSinpatches/agents@0.17.3.patch). The likely cause is the sixty-minute execute default in d8393e3.Out of scope
R2 (the blob store), Durable Object stubs and service bindings do not go through a shared seam in this host, and their transient failures look different. The incident was D1 only, so this PR does not change them.
https://claude.ai/code/session_01UBfgbNPCqMRR2hryXb1FDL