diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts b/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts index 442c8073cf92..d247b70ac483 100644 --- a/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/cloudflare'; import { WorkflowEntrypoint } from 'cloudflare:workers'; import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; +import { NonRetryableError } from 'cloudflare:workflows'; interface Env { SENTRY_DSN: string; @@ -11,6 +12,7 @@ interface WorkflowParams { failCount: number; captureManual?: boolean; captureManualTwice?: boolean; + nonRetryable?: boolean; } class StepContextTestWorkflowBase extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep): Promise { @@ -36,6 +38,10 @@ class StepContextTestWorkflowBase extends WorkflowEntrypoint 0) { remainingFailures--; throw new Error('Intentional failure for retry test'); @@ -66,10 +72,11 @@ export default Sentry.withSentry( const failCount = parseInt(url.searchParams.get('failCount') || '0', 10); const captureManual = url.searchParams.get('captureManual') === 'true'; const captureManualTwice = url.searchParams.get('captureManualTwice') === 'true'; + const nonRetryable = url.searchParams.get('nonRetryable') === 'true'; try { const instance = await env.STEP_CONTEXT_WORKFLOW.create({ - params: { failCount, captureManual, captureManualTwice }, + params: { failCount, captureManual, captureManualTwice, nonRetryable }, }); return new Response(JSON.stringify({ id: instance.id }), { headers: { 'Content-Type': 'application/json' } }); diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/test.ts b/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/test.ts index c71ad223140a..a4c8af52620b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/test.ts @@ -59,6 +59,33 @@ it('With step context, only one error is captured on final retry attempt', async await runner.completed(); }); +it('Captures a NonRetryableError thrown before the final attempt', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect((envelope: Envelope): void => { + const [, items] = envelope; + const [itemHeader, itemBody] = items[0] as [{ type: string }, Record]; + + expect(itemHeader.type).toBe('event'); + + const exception = itemBody.exception as { values?: Array<{ type?: string; value?: string }> }; + expect(exception?.values?.[0]).toMatchObject({ + type: 'NonRetryableError', + value: 'Intentional non-retryable failure', + }); + }) + .expect(flushMarkerMatcher) + .start(signal); + + const trigger = await runner.makeRequest('get', '/trigger-workflow?nonRetryable=true'); + expect(trigger?.id).toBeDefined(); + + const status = await waitForWorkflowStatus(runner.makeRequest.bind(runner), trigger!.id); + expect(status?.status?.status).toBe('errored'); + + await runner.makeRequest('get', '/flush-marker'); + await runner.completed(); +}); + it('No error event when step eventually succeeds within retry limit', async ({ signal }) => { const runner = createRunner(__dirname).expect(flushMarkerMatcher).start(signal); diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index f4869a38615e..0d31267e8176 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -11,7 +11,15 @@ import { } from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; import type { PropagationContext, Scope } from '@sentry/core'; -import { captureException, flush, getCurrentScope, startSpan, withIsolationScope, withScope } from '@sentry/core'; +import { + captureException, + flush, + getCurrentScope, + isError, + startSpan, + withIsolationScope, + withScope, +} from '@sentry/core'; import type { WorkflowDelayDuration, WorkflowEntrypoint, @@ -70,6 +78,14 @@ async function propagationContextFromInstanceId(instanceId: string): Promise { }); }); + describe('NonRetryableError before the final attempt', () => { + class NonRetryableError extends Error { + constructor(message: string, name = 'NonRetryableError') { + super(message); + this.name = name; + } + } + + async function runFailingWorkflow(createError: () => Error): Promise<{ errorEvents: any[]; attempts: number }> { + const retryLimit = 2; + let attempts = 0; + const step: WorkflowStep = { + do: vi + .fn() + .mockImplementation( + async ( + _name: string, + configOrCallback: WorkflowStepConfig | ((...args: unknown[]) => Promise), + maybeCallback?: (...args: unknown[]) => Promise, + ) => { + const callback = (typeof configOrCallback === 'function' ? configOrCallback : maybeCallback)!; + let lastError: unknown; + for (let attempt = 1; attempt <= retryLimit + 1; attempt++) { + attempts = attempt; + try { + return await callback({ attempt, config: { retries: { limit: retryLimit }, timeout: 60000 } }); + } catch (err) { + lastError = err; + // The engine's rule for ending a step without further retries + const { name, message } = err as Error; + if (name === 'NonRetryableError' || message.startsWith('NonRetryableError')) { + break; + } + } + } + throw lastError; + }, + ), + sleep: vi.fn(), + sleepUntil: vi.fn(), + waitForEvent: vi.fn(), + }; + + class FailingWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + await step.do('failing step', async () => { + throw createError(); + }); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(() => getSentryOptions(), FailingWorkflow as any); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as FailingWorkflow; + const event = { payload: {}, timestamp: new Date(), instanceId: INSTANCE_ID }; + await expect(workflow.run(event, step)).rejects.toThrow('permanent failure'); + + const errorEvents = mockTransport.send.mock.calls + .map(call => (call[0] as any)[1] as any[]) + .flatMap(items => items.filter(i => i[0].type === 'event').map(i => i[1])); + + return { errorEvents, attempts }; + } + + test('captures a NonRetryableError thrown on the first attempt', async () => { + const { errorEvents, attempts } = await runFailingWorkflow(() => new NonRetryableError('permanent failure')); + + expect(attempts).toBe(1); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]).toMatchObject({ + exception: { + values: [ + expect.objectContaining({ + type: 'NonRetryableError', + value: 'permanent failure', + mechanism: { type: 'auto.faas.cloudflare.workflow', handled: true }, + }), + ], + }, + }); + }); + + test('captures an error whose message starts with "NonRetryableError" on the first attempt', async () => { + const { errorEvents, attempts } = await runFailingWorkflow( + () => new Error('NonRetryableError: permanent failure'), + ); + + expect(attempts).toBe(1); + expect(errorEvents).toHaveLength(1); + }); + + test('captures a NonRetryableError with a custom name only on the final attempt, as the engine retries it', async () => { + const { errorEvents, attempts } = await runFailingWorkflow( + () => new NonRetryableError('permanent failure', 'MyError'), + ); + + expect(attempts).toBe(3); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]).toMatchObject({ + exception: { values: [expect.objectContaining({ type: 'MyError', value: 'permanent failure' })] }, + }); + }); + }); + test('emits error per attempt on older Cloudflare without step context', async () => { // Mock that always throws — simulates a step that fails through every retry // Does NOT pass step context (simulates older Cloudflare versions)