From 22510f3dc4fa02577ab1b711f5679c9be06c6b72 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 6 Oct 2026 11:30:35 +0200 Subject: [PATCH] fix(nextjs): Don't capture forbidden() and unauthorized() as errors Next.js 16.4 canary removes the experimental authInterrupts gate, so these throws reach our wrappers by default. Treat their NEXT_HTTP_ERROR_FALLBACK;403 and ;401 digests as control flow and set the matching span status instead of reporting them. Co-Authored-By: Claude Opus 5.5 --- .../app/auth-interrupts/forbidden/page.tsx | 7 ++ .../route-handler/forbidden/route.ts | 7 ++ .../route-handler/unauthorized/route.ts | 7 ++ .../auth-interrupts/server-action/page.tsx | 34 ++++++++ .../app/auth-interrupts/unauthorized/page.tsx | 7 ++ .../nextjs-16/next.config.ts | 1 + .../nextjs-16/tests/auth-interrupts.test.ts | 85 +++++++++++++++++++ .../src/common/nextNavigationErrorUtils.ts | 14 +++ .../common/withServerActionInstrumentation.ts | 5 ++ .../wrapGenerationFunctionWithSentry.ts | 8 ++ .../src/common/wrapRouteHandlerWithSentry.ts | 9 ++ .../common/wrapServerComponentWithSentry.ts | 8 ++ .../common/nextNavigationErrorUtils.test.ts | 17 ++++ .../wrapServerComponentWithSentry.test.ts | 14 +++ 14 files changed, 223 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/forbidden/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/route-handler/forbidden/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/route-handler/unauthorized/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/server-action/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/unauthorized/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/auth-interrupts.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/forbidden/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/forbidden/page.tsx new file mode 100644 index 000000000000..d59c333380fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/forbidden/page.tsx @@ -0,0 +1,7 @@ +import { forbidden } from 'next/navigation'; + +export const dynamic = 'force-dynamic'; + +export default function ForbiddenPage() { + forbidden(); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/route-handler/forbidden/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/route-handler/forbidden/route.ts new file mode 100644 index 000000000000..349e85337344 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/route-handler/forbidden/route.ts @@ -0,0 +1,7 @@ +import { forbidden } from 'next/navigation'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + forbidden(); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/route-handler/unauthorized/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/route-handler/unauthorized/route.ts new file mode 100644 index 000000000000..4b56478b45b9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/route-handler/unauthorized/route.ts @@ -0,0 +1,7 @@ +import { unauthorized } from 'next/navigation'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + unauthorized(); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/server-action/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/server-action/page.tsx new file mode 100644 index 000000000000..326d27592ccc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/server-action/page.tsx @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/nextjs'; +import { headers } from 'next/headers'; +import { forbidden, unauthorized } from 'next/navigation'; + +export default function AuthInterruptsServerActionPage() { + async function forbiddenServerAction() { + 'use server'; + return await Sentry.withServerActionInstrumentation('forbiddenServerAction', { headers: await headers() }, () => { + forbidden(); + }); + } + + async function unauthorizedServerAction() { + 'use server'; + return await Sentry.withServerActionInstrumentation( + 'unauthorizedServerAction', + { headers: await headers() }, + () => { + unauthorized(); + }, + ); + } + + return ( + <> +
+ +
+
+ +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/unauthorized/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/unauthorized/page.tsx new file mode 100644 index 000000000000..8929e86d1613 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/auth-interrupts/unauthorized/page.tsx @@ -0,0 +1,7 @@ +import { unauthorized } from 'next/navigation'; + +export const dynamic = 'force-dynamic'; + +export default function UnauthorizedPage() { + unauthorized(); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts index 8f6c5edfbcca..9278da016887 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -6,6 +6,7 @@ process.env.VERCEL = '1'; const nextConfig: NextConfig = { experimental: { + authInterrupts: true, sri: { algorithm: 'sha256', }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/auth-interrupts.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/auth-interrupts.test.ts new file mode 100644 index 000000000000..a24f6e2c7108 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/auth-interrupts.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +const cases = [ + { kind: 'forbidden', label: 'Forbidden', status: 403, statusMessage: 'permission_denied' }, + { kind: 'unauthorized', label: 'Unauthorized', status: 401, statusMessage: 'unauthenticated' }, +] as const; + +function waitForAuthInterruptError(status: number): Promise { + return waitForError('nextjs-16', errorEvent => { + return JSON.stringify(errorEvent.exception ?? {}).includes(`NEXT_HTTP_ERROR_FALLBACK;${status}`); + }); +} + +// The error would be sent in the same flush as the spans, so a short grace period is enough. +async function expectNoError(errorPromise: Promise): Promise { + const result = await Promise.race([ + errorPromise.then(() => 'error'), + new Promise(resolve => setTimeout(() => resolve('timeout'), 2000)), + ]); + expect(result).toBe('timeout'); +} + +for (const { kind, label, status, statusMessage } of cases) { + test(`Does not capture ${kind}() in a server component and marks the render span`, async ({ page }) => { + const errorPromise = waitForAuthInterruptError(status); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', `GET /auth-interrupts/${kind}`); + + const response = await page.goto(`/auth-interrupts/${kind}`); + expect(response?.status()).toBe(status); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + expect(segmentSpan.status).toBe('error'); + expect(segmentSpan.attributes['http.response.status_code']?.value).toBe(status); + + // Server components are only wrapped by our webpack loader, so only webpack builds set a status on the render span. + if (!segmentSpan.attributes['turbopack']) { + expect(spans).toContainEqual( + expect.objectContaining({ + name: `render route (app) /auth-interrupts/${kind}`, + status: 'error', + attributes: expect.objectContaining({ + 'sentry.status.message': { value: statusMessage, type: 'string' }, + }), + }), + ); + } + + await expectNoError(errorPromise); + }); + + test(`Does not capture ${kind}() in a server action and marks the action span`, async ({ page }) => { + const errorPromise = waitForAuthInterruptError(status); + const spanPromise = waitForStreamedSpan('nextjs-16', span => { + return span.name === `${kind}ServerAction` && span.is_segment; + }); + + await page.goto('/auth-interrupts/server-action'); + await page.getByText(`Run ${label} Action`).click(); + + const span = await spanPromise; + expect(span.status).toBe('error'); + expect(span.attributes['sentry.status.message']?.value).toBe(statusMessage); + + await expectNoError(errorPromise); + }); + + test(`Does not capture ${kind}() in a route handler and sets the response status`, async ({ request }) => { + const errorPromise = waitForAuthInterruptError(status); + const spanPromise = waitForStreamedSpan('nextjs-16', span => { + return span.name === `GET /auth-interrupts/route-handler/${kind}` && span.is_segment; + }); + + const response = await request.get(`/auth-interrupts/route-handler/${kind}`); + expect(response.status()).toBe(status); + + const span = await spanPromise; + expect(span.status).toBe('error'); + expect(span.attributes['sentry.status.message']?.value).toBe(statusMessage); + expect(span.attributes['http.response.status_code']?.value).toBe(status); + + await expectNoError(errorPromise); + }); +} diff --git a/packages/nextjs/src/common/nextNavigationErrorUtils.ts b/packages/nextjs/src/common/nextNavigationErrorUtils.ts index 94960cc32ca9..9ad2674729ea 100644 --- a/packages/nextjs/src/common/nextNavigationErrorUtils.ts +++ b/packages/nextjs/src/common/nextNavigationErrorUtils.ts @@ -29,6 +29,20 @@ export function isNotFoundNavigationError(subject: unknown): boolean { return hasDigest(subject, digest => ['NEXT_NOT_FOUND', 'NEXT_HTTP_ERROR_FALLBACK;404'].includes(digest)); } +/** + * Returns the HTTP status of a Next.js `forbidden()` (403) or `unauthorized()` (401) error, if input is one. + * https://nextjs.org/docs/app/api-reference/functions/forbidden + */ +export function getAuthInterruptStatusCode(subject: unknown): 401 | 403 | undefined { + if (hasDigest(subject, digest => digest === 'NEXT_HTTP_ERROR_FALLBACK;403')) { + return 403; + } + if (hasDigest(subject, digest => digest === 'NEXT_HTTP_ERROR_FALLBACK;401')) { + return 401; + } + return undefined; +} + /** * Determines whether input is a Next.js redirect error. * https://beta.nextjs.org/docs/api-reference/redirect#redirect diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index e660682282d6..3f4587f80a35 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -6,6 +6,7 @@ import { getActiveSpan, getClient, getIsolationScope, + getSpanStatusFromHttpCode, handleCallbackErrors, hasSpanStreamingEnabled, SPAN_STATUS_ERROR, @@ -16,6 +17,7 @@ import { import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; import { DEBUG_BUILD } from './debug-build'; import { + getAuthInterruptStatusCode, isNotFoundNavigationError, isPrerenderControlFlowError, isRedirectNavigationError, @@ -144,9 +146,12 @@ async function withServerActionInstrumentationImplementation { // oxlint-disable-next-line typescript/await-thenable -- callback may be async at runtime const result = await handleCallbackErrors(callback, error => { + const authInterruptStatusCode = getAuthInterruptStatusCode(error); if (isNotFoundNavigationError(error)) { // We don't want to report "not-found"s span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + } else if (authInterruptStatusCode) { + span.setStatus(getSpanStatusFromHttpCode(authInterruptStatusCode)); } else if (isRedirectNavigationError(error)) { // Redirects are normal Next.js control flow, not errors. Mark the span as OK and end it // early so the surrounding `startSpan` error handler doesn't override the status to diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index d90678ded6ed..29d609dae31d 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -3,6 +3,7 @@ import { captureException, getActiveSpan, getIsolationScope, + getSpanStatusFromHttpCode, handleCallbackErrors, SPAN_STATUS_ERROR, SPAN_STATUS_OK, @@ -10,6 +11,7 @@ import { } from '@sentry/core'; import type { GenerationFunctionContext } from '../common/types'; import { + getAuthInterruptStatusCode, isNotFoundNavigationError, isPrerenderControlFlowError, isRedirectNavigationError, @@ -59,6 +61,12 @@ export function wrapGenerationFunctionWithSentry a return; } + const authInterruptStatusCode = getAuthInterruptStatusCode(error); + if (authInterruptStatusCode) { + span?.setStatus(getSpanStatusFromHttpCode(authInterruptStatusCode)); + return; + } + if (isRedirectNavigationError(error)) { // We don't want to report redirects span?.setStatus({ code: SPAN_STATUS_OK }); diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index cbefbc2f079c..7ed7b15eb0ba 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -16,6 +16,7 @@ import { withScope, } from '@sentry/core'; import { + getAuthInterruptStatusCode, isNotFoundNavigationError, isPrerenderControlFlowError, isRedirectNavigationError, @@ -83,6 +84,7 @@ export function wrapRouteHandlerWithSentry any>( const response: Response = await handleCallbackErrors( () => originalFunction.apply(thisArg, args), error => { + const authInterruptStatusCode = getAuthInterruptStatusCode(error); // Next.js throws errors when calling `redirect()`. We don't wanna report these. if (isRedirectNavigationError(error)) { // Don't do anything @@ -93,6 +95,13 @@ export function wrapRouteHandlerWithSentry any>( if (rootSpan) { setHttpStatus(rootSpan, 404); } + } else if (authInterruptStatusCode) { + if (activeSpan) { + setHttpStatus(activeSpan, authInterruptStatusCode); + } + if (rootSpan) { + setHttpStatus(rootSpan, authInterruptStatusCode); + } } else if (isPrerenderControlFlowError(error)) { // Next.js aborts prerenders by rejecting the promises it handed out. React discards those // rejections, so they are expected, do not affect the response, and must not be reported. diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 6deaef200f67..9f96f5a912c1 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -3,12 +3,14 @@ import { captureException, getActiveSpan, getIsolationScope, + getSpanStatusFromHttpCode, handleCallbackErrors, SPAN_STATUS_ERROR, SPAN_STATUS_OK, winterCGHeadersToDict, } from '@sentry/core'; import { + getAuthInterruptStatusCode, isNotFoundNavigationError, isPrerenderControlFlowError, isRedirectNavigationError, @@ -54,6 +56,12 @@ export function wrapServerComponentWithSentry any> return; } + const authInterruptStatusCode = getAuthInterruptStatusCode(error); + if (authInterruptStatusCode) { + span?.setStatus(getSpanStatusFromHttpCode(authInterruptStatusCode)); + return; + } + if (isRedirectNavigationError(error)) { // We don't want to report redirects span?.setStatus({ code: SPAN_STATUS_OK }); diff --git a/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts b/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts index 072893b8f53b..e0e2c482b737 100644 --- a/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts +++ b/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getAuthInterruptStatusCode, isNotFoundNavigationError, isPrerenderControlFlowError, isRedirectNavigationError, @@ -21,6 +22,22 @@ describe('isNotFoundNavigationError', () => { }); }); +describe('getAuthInterruptStatusCode', () => { + it.each([ + ['NEXT_HTTP_ERROR_FALLBACK;403', 403], + ['NEXT_HTTP_ERROR_FALLBACK;401', 401], + ])('returns the status for the %s digest', (digest, status) => { + expect(getAuthInterruptStatusCode(errorWithDigest(digest))).toBe(status); + expect(getAuthInterruptStatusCode(new Error('wrapped', { cause: errorWithDigest(digest) }))).toBe(status); + }); + + it('returns undefined for other errors', () => { + expect(getAuthInterruptStatusCode(errorWithDigest('NEXT_HTTP_ERROR_FALLBACK;404'))).toBeUndefined(); + expect(getAuthInterruptStatusCode(new Error('boom'))).toBeUndefined(); + expect(getAuthInterruptStatusCode({ digest: 'NEXT_HTTP_ERROR_FALLBACK;403' })).toBeUndefined(); + }); +}); + describe('isRedirectNavigationError', () => { it('detects a redirect digest', () => { expect(isRedirectNavigationError(errorWithDigest('NEXT_REDIRECT;/some-path'))).toBe(true); diff --git a/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts b/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts index aed2553c7baa..8556f5bda8df 100644 --- a/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts +++ b/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts @@ -40,6 +40,8 @@ describe('wrapServerComponentWithSentry', () => { it.each([ ['not-found', 'NEXT_NOT_FOUND'], ['redirect', 'NEXT_REDIRECT;/somewhere'], + ['forbidden', 'NEXT_HTTP_ERROR_FALLBACK;403'], + ['unauthorized', 'NEXT_HTTP_ERROR_FALLBACK;401'], ['hanging prerender promise', 'HANGING_PROMISE_REJECTION'], ['prerender interruption', 'NEXT_PRERENDER_INTERRUPTED'], ['dynamic server usage', 'DYNAMIC_SERVER_USAGE'], @@ -53,6 +55,18 @@ describe('wrapServerComponentWithSentry', () => { expect(captureException).not.toHaveBeenCalled(); }); + it.each([ + ['NEXT_HTTP_ERROR_FALLBACK;403', 'permission_denied'], + ['NEXT_HTTP_ERROR_FALLBACK;401', 'unauthenticated'], + ])('marks the active span for the %s auth interrupt', async (digest, message) => { + vi.spyOn(SentryCore, 'captureException').mockImplementation(() => ''); + const { setStatus } = mockActiveSpan({} as Span); + + await runWrapped(Object.assign(new Error('control flow'), { digest })); + + expect(setStatus).toHaveBeenCalledWith({ code: SentryCore.SPAN_STATUS_ERROR, message }); + }); + it.each(['NEXT_NOT_FOUND', 'NEXT_REDIRECT;/somewhere', 'HANGING_PROMISE_REJECTION'])( 'does not capture the %s control flow error when there is no active span', async digest => {