From 07304942f86065d380099dd32b14120d805dfe2c Mon Sep 17 00:00:00 2001 From: Zheng Li Date: Thu, 11 Jun 2026 06:54:08 +0800 Subject: [PATCH] test(api/e2e): retry transient D1 HTTP errors in seed helper Mirror the application-side retry policy from lib/db/d1-client.ts so seed.ts executeD1/queryD1 survive a single ECONNRESET / dropped TCP connection to api.cloudflare.com instead of aborting the whole vitest run. CI run 27265783211 hit this in tests/api/v1/idempotency.test.ts line 109 (queryD1 verifying link_tags row count), with the failure recorded as TypeError: fetch failed -> Error: read ECONNRESET. The application path already retries; only the test seed/query helper was unprotected. Retries are bounded (2 attempts on top of the original call) and only triggered for known transient errors (ECONNRESET, ECONNREFUSED, ETIMEDOUT, UND_ERR_SOCKET, fetch failed). The warning includes the underlying syscall code so non-transient failures stay distinguishable in CI logs. --- tests/api/helpers/seed.ts | 48 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/tests/api/helpers/seed.ts b/tests/api/helpers/seed.ts index e80b772..2199c5f 100644 --- a/tests/api/helpers/seed.ts +++ b/tests/api/helpers/seed.ts @@ -52,6 +52,48 @@ function ensureEnv(): void { // D1 HTTP API // --------------------------------------------------------------------------- +/** + * Retry transient network errors (ECONNRESET, fetch aborted, etc.) when + * talking to the Cloudflare D1 HTTP API. Mirrors the application-side retry + * policy in lib/db/d1-client.ts so CI test runs are not aborted by a single + * dropped TCP connection (observed in run 27265783211). + */ +const D1_MAX_RETRIES = 2; +const D1_RETRY_BASE_DELAY_MS = 200; + +function isTransientD1Error(err: unknown): boolean { + if (err instanceof TypeError && err.message === 'fetch failed') return true; + const msg = err instanceof Error ? err.message : ''; + return ( + msg.includes('ECONNRESET') || + msg.includes('ECONNREFUSED') || + msg.includes('ETIMEDOUT') || + msg.includes('UND_ERR_SOCKET') + ); +} + +async function d1FetchWithRetry(url: string, init: RequestInit, label: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= D1_MAX_RETRIES; attempt++) { + try { + return await fetch(url, init); + } catch (err) { + lastError = err; + if (attempt < D1_MAX_RETRIES && isTransientD1Error(err)) { + const delay = D1_RETRY_BASE_DELAY_MS * 2 ** attempt; + console.warn( + `[${label}] Transient D1 error (attempt ${attempt + 1}/${D1_MAX_RETRIES + 1}), retrying in ${delay}ms:`, + err instanceof Error ? `${err.message} (cause: ${(err as { cause?: { code?: string } }).cause?.code ?? 'n/a'})` : err, + ); + await new Promise((r) => setTimeout(r, delay)); + continue; + } + throw err; + } + } + throw lastError; +} + function d1Credentials(): { accountId: string; databaseId: string; token: string } { ensureEnv(); const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; @@ -83,13 +125,14 @@ function d1Credentials(): { accountId: string; databaseId: string; token: string export async function executeD1(sql: string, params: unknown[] = []): Promise { const { accountId, databaseId, token } = d1Credentials(); - const res = await fetch( + const res = await d1FetchWithRetry( `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ sql, params }), }, + 'D1 execute', ); if (!res.ok) { const body = await res.text(); @@ -107,13 +150,14 @@ export async function queryD1>( params: unknown[] = [], ): Promise { const { accountId, databaseId, token } = d1Credentials(); - const res = await fetch( + const res = await d1FetchWithRetry( `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ sql, params }), }, + 'D1 query', ); if (!res.ok) { const body = await res.text();