Skip to content

Adjust cache life for error responses #3423

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 7, 2025
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
121 changes: 121 additions & 0 deletions packages/gitbook/src/lib/data/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'bun:test';
import { GitBookAPIError } from '@gitbook/api';
import { extractCacheControl } from './errors';

describe('extractCacheControl', () => {
it('should return undefined when error has no response', () => {
const error = { message: 'Test error' } as GitBookAPIError;
const result = extractCacheControl(error);
expect(result).toBeUndefined();
});

it('should return undefined when response has no cache-control header', () => {
const error = new GitBookAPIError('Test error', new Response(null, {}));

const result = extractCacheControl(error);
expect(result).toBeUndefined();
});

it('should parse max-age from cache-control header', () => {
const error = new GitBookAPIError(
'Test error',
new Response(null, {
headers: {
'cache-control': 'max-age=3600',
},
})
);

const result = extractCacheControl(error);
expect(result).toEqual({
maxAge: 3600,
staleWhileRevalidate: undefined,
});
});

it('should parse stale-while-revalidate from cache-control header', () => {
const error = new GitBookAPIError(
'Test error',
new Response(null, {
headers: {
'cache-control': 'stale-while-revalidate=86400',
},
})
);

const result = extractCacheControl(error);
expect(result).toEqual({
maxAge: undefined,
staleWhileRevalidate: 86400,
});
});

it('should parse both max-age and stale-while-revalidate', () => {
const error = new GitBookAPIError(
'Test error',
new Response(null, {
headers: {
'cache-control': 'max-age=3600, stale-while-revalidate=86400',
},
})
);

const result = extractCacheControl(error);
expect(result).toEqual({
maxAge: 3600,
staleWhileRevalidate: 86400,
});
});

it('should return undefined for maxAge when it is 0', () => {
const error = new GitBookAPIError(
'Test error',
new Response(null, {
headers: {
'cache-control': 'max-age=0, stale-while-revalidate=86400',
},
})
);

const result = extractCacheControl(error);
expect(result).toEqual({
maxAge: undefined,
staleWhileRevalidate: 86400,
});
});

it('should handle complex cache-control header with multiple directives', () => {
const error = new GitBookAPIError(
'Test error',
new Response(null, {
headers: {
'cache-control':
'public, max-age=3600, must-revalidate, stale-while-revalidate=86400',
},
})
);

const result = extractCacheControl(error);
expect(result).toEqual({
maxAge: 3600,
staleWhileRevalidate: 86400,
});
});

it('should return undefined when cache-control has no parseable values', () => {
const error = new GitBookAPIError(
'Test error',
new Response(null, {
headers: {
'cache-control': 'no-cache, no-store',
},
})
);

const result = extractCacheControl(error);
expect(result).toEqual({
maxAge: undefined,
staleWhileRevalidate: undefined,
});
});
});
58 changes: 54 additions & 4 deletions packages/gitbook/src/lib/data/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { GitBookAPIError } from '@gitbook/api';
import { unstable_cacheLife as cacheLife } from 'next/cache';
import type { DataFetcherErrorData, DataFetcherResponse } from './types';

import parseCacheControl from 'parse-cache-control';

export class DataFetcherError extends Error {
constructor(
message: string,
Expand Down Expand Up @@ -98,10 +100,22 @@ export async function wrapCacheDataFetcherError<T>(
fn: () => Promise<T>
): Promise<DataFetcherResponse<T>> {
const result = await wrapDataFetcherError(fn);
if (result.error && result.error.code >= 500) {
// We don't want to cache errors for too long.
// as the API might
cacheLife('minutes');
if (result.error) {
const cacheValue = result.error.cache;
// We only want to cache 404 errors for "long", because that's an "expected" error.
if (result.error.code === 404) {
cacheLife({
stale: 60,
revalidate: cacheValue?.maxAge ?? 60 * 60, // 1 hour
expire: cacheValue?.staleWhileRevalidate ?? 60 * 60 * 24, // 1 day
});
} else {
cacheLife({
stale: 60, // This one is only for the client
revalidate: cacheValue?.maxAge ?? 30, // we don't want to cache it for too long, but at least 30 seconds to avoid hammering the API
expire: cacheValue?.staleWhileRevalidate ?? 90, // we want to revalidate this error after 90 seconds for sure
});
}
}
return result;
}
Expand Down Expand Up @@ -134,6 +148,40 @@ export function ignoreDataFetcherErrors<T>(
return response;
}

/**
* Extract cache control information from a GitBookAPIError.
* If the error does not have a response or no cache-control, it returns undefined.
*/
export function extractCacheControl(error: GitBookAPIError) {
try {
if (!error.response) {
return undefined;
}

const cacheControl = error.response.headers.get('cache-control');
if (!cacheControl) {
return undefined;
}
const parsed = parseCacheControl(cacheControl);

//parseCacheControl does not support stale-while-revalidate, so we need to parse it manually
const staleWhileRevalidateMatch = cacheControl.match(/stale-while-revalidate=(\d+)/i);

const maxAge = parsed?.['max-age'];
const staleWhileRevalidate = staleWhileRevalidateMatch
? Number.parseInt(staleWhileRevalidateMatch[1], 10)
: undefined;

return {
// If maxAge is 0, we want to apply the default, not 0
maxAge: maxAge === 0 ? undefined : maxAge,
staleWhileRevalidate,
};
} catch {
return undefined;
}
}

/**
* Get a data fetcher exposable error from a JS error.
*/
Expand All @@ -142,10 +190,12 @@ export function getExposableError(error: Error): DataFetcherErrorData {
if (error.code >= 500) {
throw error;
}
const cache = extractCacheControl(error);

return {
code: error.code,
message: error.errorMessage,
cache,
};
}

Expand Down
4 changes: 4 additions & 0 deletions packages/gitbook/src/lib/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type * as api from '@gitbook/api';
export type DataFetcherErrorData = {
code: number;
message: string;
cache?: {
maxAge?: number;
staleWhileRevalidate?: number;
};
};

export type DataFetcherResponse<T> =
Expand Down