From 75db00c07e4f9a3da7dd288aca512a11a0f94ac2 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 30 Jul 2026 09:13:22 +0200 Subject: [PATCH] test(cloudflare): Skip retries for expected failures Avoid retrying intentional worker failures, which only repeats exceptions and slows tests. Add Durable Object and scheduled-handler error coverage. Co-authored-by: Cursor Co-Authored-By: Claude --- .../cloudflare-integration-tests/runner.ts | 13 +++++- .../suites/durableobject/error/index.ts | 42 +++++++++++++++++++ .../suites/durableobject/error/test.ts | 30 +++++++++++++ .../suites/durableobject/error/wrangler.jsonc | 20 +++++++++ .../suites/tracing/headers/test.ts | 2 +- .../suites/tracing/scheduled/index.ts | 6 ++- .../suites/tracing/scheduled/test.ts | 20 +++++++++ 7 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/error/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/error/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 252d5dc84031..1340505c7c22 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -72,11 +72,20 @@ function builtFromSource(builtConfigPath: string, sourceConfigPath: string): boo } } +type RetryOptions = { maxRetries?: number; retryDelayMs?: number }; + // Wrangler can report "Ready" before it can actually handle requests. // This retries fetch on connection errors and transient 500 responses to handle this race condition. // The budget (maxRetries * retryDelayMs) must cover the "ready-but-not-serving" window, which can be // several seconds on a loaded CI runner — hence a generous default. -async function fetchWithRetry(url: string, init: RequestInit, maxRetries = 25, retryDelayMs = 200): Promise { +// +// Requests expected to fail must disable retries (`maxRetries: 1`), because their 500 or connection +// reset is indistinguishable from a transient startup failure and retrying only repeats the exception. +async function fetchWithRetry( + url: string, + init: RequestInit, + { maxRetries = 25, retryDelayMs = 200 }: RetryOptions = {}, +): Promise { for (let attempt = 0; attempt < maxRetries; attempt++) { try { const res = await fetch(url, init); @@ -429,7 +438,7 @@ export function createRunner(...paths: string[]) { if (process.env.DEBUG) log('making request', method, url, headers, body); try { - const res = await fetchWithRetry(url, { headers, method, body }); + const res = await fetchWithRetry(url, { headers, method, body }, expectError ? { maxRetries: 1 } : {}); if (!res.ok) { if (!expectError) { diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts new file mode 100644 index 000000000000..c2a1ba92b8ce --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts @@ -0,0 +1,42 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; +} + +class TestDurableObjectBase extends DurableObject { + public constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } + + async fetch(_request: Request): Promise { + throw new Error('Test error from Durable Object fetch handler'); + } +} + +export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, + }), + TestDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, + }), + { + async fetch(_request: Request, env: Env): Promise { + const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test'); + const stub = env.TEST_DURABLE_OBJECT.get(id); + + return stub.fetch('http://durable-object/'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/error/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/test.ts new file mode 100644 index 000000000000..dd8988e30170 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('captures errors thrown by a Durable Object fetch handler', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.type).toBe('Error'); + expect(event.exception?.values?.[0]?.value).toBe('Test error from Durable Object fetch handler'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.type).toBe('Error'); + expect(event.exception?.values?.[0]?.value).toBe('Test error from Durable Object fetch handler'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.http.cloudflare', + handled: false, + }); + }) + .unordered() + .start(signal); + + await runner.makeRequest('get', '/', { expectError: true }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/error/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/wrangler.jsonc new file mode 100644 index 000000000000..be10ba5f079d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "durableobject-error-worker", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "migrations": [ + { + "new_sqlite_classes": ["TestDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "TestDurableObject", + "name": "TEST_DURABLE_OBJECT", + }, + ], + }, + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts index e73e04583ffb..742bc20051ad 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts @@ -58,7 +58,7 @@ it('Tracing headers', async ({ signal }) => { ) .start(signal); - await runner.makeRequest('get', '/'); + await runner.makeRequest('get', '/', { expectError: true }); await runner.completed(); closeTestServer(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts index 0ce111349892..8e36aa1f8ea4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts @@ -14,7 +14,11 @@ export default Sentry.withSentry( async fetch(_request, _env, _ctx) { return new Response('OK'); }, - async scheduled(_controller, _env, _ctx) { + async scheduled(controller, _env, _ctx) { + if (controller.cron === '0 0 * * *') { + throw new Error('Test error from scheduled handler'); + } + // Successful scheduled handler - just does some work await new Promise(resolve => setTimeout(resolve, 10)); }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts index 3ba87e1a22bb..32fa1c7eee00 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts @@ -1,4 +1,5 @@ import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -44,3 +45,22 @@ it('Scheduled handler creates transaction with correct attributes', async ({ sig await runner.makeRequest('get', '/__scheduled'); await runner.completed(); }); + +it('captures errors thrown by the scheduled handler', async ({ signal }) => { + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.type).toBe('Error'); + expect(event.exception?.values?.[0]?.value).toBe('Test error from scheduled handler'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.faas.cloudflare.scheduled', + handled: false, + }); + }) + .unordered() + .start(signal); + + await runner.makeRequest('get', `/__scheduled?cron=${encodeURIComponent('0 0 * * *')}`, { expectError: true }); + await runner.completed(); +});