Skip to content
Open
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
35 changes: 19 additions & 16 deletions app/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
getVersionsFromDoc,
} from 'sentry-docs/mdx';
import {mdxComponents} from 'sentry-docs/mdxComponents';
import {isExpectedMdxError} from 'sentry-docs/mdxErrors';
import {PageType} from 'sentry-docs/metrics';
import {setServerContext} from 'sentry-docs/serverContext';
import {PaginationNavNode} from 'sentry-docs/types/paginationNavNode';
Expand Down Expand Up @@ -162,21 +163,22 @@ export default async function Page(props: {params: Promise<{path?: string[]}>})
// This can happen when serverless function is invoked at runtime but docs files
// aren't in the bundle, or MDX compilation is attempted at Vercel runtime.
// Note: This error handling is duplicated for regular docs below - keep them in sync.
const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null;
const isExpectedError =
errorCode === 'ENOENT' ||
errorCode === 'MDX_RUNTIME_ERROR' ||
(e instanceof Error && e.message.includes('Failed to find a valid source file'));
if (isExpectedError) {
// Log as warning for visibility without flooding errors
// Users are served static pages from CDN - this is an infrastructure edge case
if (isExpectedMdxError(e)) {
const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null;
Sentry.logger.warn('MDX file not found at runtime, returning 404', {
path: params.path?.join('/'),
errorCode,
reason: 'serverless_bundle_exclusion',
});
return notFound();
}
// TODO(diagnostic): remove once Edge runtime error shape is confirmed
const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null;
Sentry.logger.error('Unexpected error loading MDX, re-throwing as 500', {
path: params.path?.join('/'),
errorCode,
hasMessage: typeof (e as any)?.message === 'string',
});
throw e;
}
const {mdxSource, frontMatter} = doc;
Expand Down Expand Up @@ -237,21 +239,22 @@ export default async function Page(props: {params: Promise<{path?: string[]}>})
// This can happen when serverless function is invoked at runtime but docs files
// aren't in the bundle, or MDX compilation is attempted at Vercel runtime.
// Note: This error handling is duplicated for developer docs above - keep them in sync.
const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null;
const isExpectedError =
errorCode === 'ENOENT' ||
errorCode === 'MDX_RUNTIME_ERROR' ||
(e instanceof Error && e.message.includes('Failed to find a valid source file'));
if (isExpectedError) {
// Log as warning for visibility without flooding errors
// Users are served static pages from CDN - this is an infrastructure edge case
if (isExpectedMdxError(e)) {
const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null;
Sentry.logger.warn('MDX file not found at runtime, returning 404', {
path: pageNode.path,
errorCode,
reason: 'serverless_bundle_exclusion',
});
return notFound();
}
// TODO(diagnostic): remove once Edge runtime error shape is confirmed
const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null;
Sentry.logger.error('Unexpected error loading MDX, re-throwing as 500', {
path: pageNode.path,
errorCode,
hasMessage: typeof (e as any)?.message === 'string',
});
throw e;
}
const {mdxSource, frontMatter} = doc;
Expand Down
5 changes: 3 additions & 2 deletions src/components/include.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {useMemo} from 'react';
import {getMDXComponent} from 'sentry-docs/getMDXComponent';
import {getFileBySlugWithCache} from 'sentry-docs/mdx';
import {mdxComponents} from 'sentry-docs/mdxComponents';
import {isExpectedMdxError} from 'sentry-docs/mdxErrors';

import {PlatformContent} from './platformContent';

Expand All @@ -21,9 +22,9 @@ export async function Include({name}: Props) {
}
try {
doc = await getFileBySlugWithCache(`includes/${name}`);
} catch (e) {
} catch (e: unknown) {
// Handle file not found (ENOENT) and runtime MDX compilation attempts gracefully
if (e.code === 'ENOENT' || e.code === 'MDX_RUNTIME_ERROR') {
if (isExpectedMdxError(e)) {
return null;
}
throw e;
Expand Down
70 changes: 68 additions & 2 deletions src/mdx.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {describe, expect, test} from 'vitest';
import {afterEach, describe, expect, test, vi} from 'vitest';

import {addVersionToFilePath, getVersionedIndexPath, getVersionsFromDoc} from './mdx';
import {
addVersionToFilePath,
getFileBySlug,
getVersionedIndexPath,
getVersionsFromDoc,
} from './mdx';
import {isExpectedMdxError} from './mdxErrors';
import {FrontMatter} from './types';

const mockFm: FrontMatter[] = [
Expand Down Expand Up @@ -117,4 +123,64 @@ describe('mdx', () => {
);
});
});

describe('getFileBySlug error contracts', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

test('throws error with .code = ENOENT for non-existent slug', async () => {
let thrown: unknown;
try {
await getFileBySlug('nonexistent/path/that/does/not/exist');
} catch (e) {
thrown = e;
}
expect(thrown).toBeDefined();
expect(thrown).toHaveProperty('code', 'ENOENT');
expect(thrown).toHaveProperty('message');
expect((thrown as Error).message).toContain('Failed to find a valid source file');
});

test('ENOENT error is recognized by isExpectedMdxError', async () => {
let thrown: unknown;
try {
await getFileBySlug('nonexistent/path/that/does/not/exist');
} catch (e) {
thrown = e;
}
expect(thrown).toBeDefined();
expect(isExpectedMdxError(thrown)).toBe(true);
});

test('throws error with .code = MDX_RUNTIME_ERROR in Vercel runtime', async () => {
vi.stubEnv('VERCEL', '1');
vi.stubEnv('NODE_ENV', 'production');
delete process.env.CI;

let thrown: unknown;
try {
await getFileBySlug('any/slug');
} catch (e) {
thrown = e;
}
expect(thrown).toBeDefined();
expect(thrown).toHaveProperty('code', 'MDX_RUNTIME_ERROR');
});

test('MDX_RUNTIME_ERROR is recognized by isExpectedMdxError', async () => {
vi.stubEnv('VERCEL', '1');
vi.stubEnv('NODE_ENV', 'production');
delete process.env.CI;

let thrown: unknown;
try {
await getFileBySlug('any/slug');
} catch (e) {
thrown = e;
}
expect(thrown).toBeDefined();
expect(isExpectedMdxError(thrown)).toBe(true);
});
});
});
78 changes: 78 additions & 0 deletions src/mdxErrors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {describe, expect, test} from 'vitest';

import {isExpectedMdxError} from './mdxErrors';

describe('isExpectedMdxError', () => {
describe('returns true for expected errors', () => {
test('Error with .code = ENOENT', () => {
const error = Object.assign(new Error('file not found'), {code: 'ENOENT'});
expect(isExpectedMdxError(error)).toBe(true);
});

test('Error with .code = MDX_RUNTIME_ERROR', () => {
const error = Object.assign(new Error('runtime error'), {
code: 'MDX_RUNTIME_ERROR',
});
expect(isExpectedMdxError(error)).toBe(true);
});

test('plain object with .code = ENOENT (Edge runtime simulation)', () => {
const error = {code: 'ENOENT', message: 'some error'};
expect(isExpectedMdxError(error)).toBe(true);
});

test('plain object with .code = MDX_RUNTIME_ERROR (Edge runtime simulation)', () => {
const error = {code: 'MDX_RUNTIME_ERROR', message: 'runtime error'};
expect(isExpectedMdxError(error)).toBe(true);
});

test('plain object with expected message but no .code (Edge runtime fallback)', () => {
const error = {
message: 'Failed to find a valid source file for slug "docs/changelog"',
};
expect(isExpectedMdxError(error)).toBe(true);
});

test('Error with expected message but no .code', () => {
const error = new Error(
'Failed to find a valid source file for slug "docs/changelog"'
);
expect(isExpectedMdxError(error)).toBe(true);
});
});

describe('returns false for unexpected errors', () => {
test('unrelated Error', () => {
expect(isExpectedMdxError(new Error('Connection refused'))).toBe(false);
});

test('Error with unrelated .code', () => {
const error = Object.assign(new Error('permission denied'), {code: 'EPERM'});
expect(isExpectedMdxError(error)).toBe(false);
});

test('plain object with wrong message and no .code', () => {
expect(isExpectedMdxError({message: 'Something else went wrong'})).toBe(false);
});

test('string throw', () => {
expect(isExpectedMdxError('some string error')).toBe(false);
});

test('null', () => {
expect(isExpectedMdxError(null)).toBe(false);
});

test('undefined', () => {
expect(isExpectedMdxError(undefined)).toBe(false);
});

test('number', () => {
expect(isExpectedMdxError(42)).toBe(false);
});

test('empty object', () => {
expect(isExpectedMdxError({})).toBe(false);
});
});
});
27 changes: 27 additions & 0 deletions src/mdxErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Determines whether a caught error represents an expected "page not found"
* condition that should result in a 404, rather than an unhandled 500.
*
* Uses duck-typing (.code property check) rather than instanceof, because
* instanceof Error returns false across Edge runtime VM context boundaries.
* Falls back to message-string matching for resilience in case .code is
* stripped during serialization.
*/
export function isExpectedMdxError(e: unknown): boolean {
const errorCode =
e && typeof e === 'object' && 'code' in e ? (e as {code: unknown}).code : null;

if (errorCode === 'ENOENT' || errorCode === 'MDX_RUNTIME_ERROR') {
return true;
}

// Fallback: duck-type the message for Edge runtime where .code may be stripped
if (
typeof (e as any)?.message === 'string' &&
(e as any).message.includes('Failed to find a valid source file')
) {
return true;
}

return false;
}
Loading