Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,6 +12,7 @@ interface WorkflowParams {
failCount: number;
captureManual?: boolean;
captureManualTwice?: boolean;
nonRetryable?: boolean;
}
class StepContextTestWorkflowBase extends WorkflowEntrypoint<Env, WorkflowParams> {
async run(event: WorkflowEvent<WorkflowParams>, step: WorkflowStep): Promise<void> {
Expand All @@ -36,6 +38,10 @@ class StepContextTestWorkflowBase extends WorkflowEntrypoint<Env, WorkflowParams
Sentry.captureException(new Error('Manual capture'));
}

if (event.payload.nonRetryable) {
throw new NonRetryableError('Intentional non-retryable failure');
}

if (remainingFailures > 0) {
remainingFailures--;
throw new Error('Intentional failure for retry test');
Expand Down Expand Up @@ -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' } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>];

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<TriggerResponse>('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);

Expand Down
20 changes: 18 additions & 2 deletions packages/cloudflare/src/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -70,6 +78,14 @@ async function propagationContextFromInstanceId(instanceId: string): Promise<Pro
};
}

// Same check the Workflows engine uses to stop retrying a step. Not `constructor.name`: a
// `NonRetryableError` with a custom `name` is retried, so capturing it early would duplicate events.
function isNonRetryableError(error: unknown): boolean {
return (
isError(error) && (error.name === 'NonRetryableError' || String(error.message).startsWith('NonRetryableError'))
);
}

class WrappedWorkflowStep implements WorkflowStep {
public constructor(
private _instanceId: string,
Expand Down Expand Up @@ -158,7 +174,7 @@ class WrappedWorkflowStep implements WorkflowStep {
span.setStatus({ code: 1 });
return result;
} catch (error) {
if (isFinalAttempt) {
if (isFinalAttempt || isNonRetryableError(error)) {
captureException(error, { mechanism: { handled: true, type: 'auto.faas.cloudflare.workflow' } });
}
throw error;
Expand Down
105 changes: 105 additions & 0 deletions packages/cloudflare/test/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,111 @@ describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => {
});
});

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<any>),
maybeCallback?: (...args: unknown[]) => Promise<any>,
) => {
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<WorkflowEvent<Params>>, step: WorkflowStep): Promise<void> {
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)
Expand Down
Loading