Skip to content

Commit 377a489

Browse files
conico974Nicolas Dorseuil
andauthored
Adjust cache life for error responses (#3423)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
1 parent 938bdeb commit 377a489

File tree

3 files changed

+179
-4
lines changed

3 files changed

+179
-4
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { GitBookAPIError } from '@gitbook/api';
3+
import { extractCacheControl } from './errors';
4+
5+
describe('extractCacheControl', () => {
6+
it('should return undefined when error has no response', () => {
7+
const error = { message: 'Test error' } as GitBookAPIError;
8+
const result = extractCacheControl(error);
9+
expect(result).toBeUndefined();
10+
});
11+
12+
it('should return undefined when response has no cache-control header', () => {
13+
const error = new GitBookAPIError('Test error', new Response(null, {}));
14+
15+
const result = extractCacheControl(error);
16+
expect(result).toBeUndefined();
17+
});
18+
19+
it('should parse max-age from cache-control header', () => {
20+
const error = new GitBookAPIError(
21+
'Test error',
22+
new Response(null, {
23+
headers: {
24+
'cache-control': 'max-age=3600',
25+
},
26+
})
27+
);
28+
29+
const result = extractCacheControl(error);
30+
expect(result).toEqual({
31+
maxAge: 3600,
32+
staleWhileRevalidate: undefined,
33+
});
34+
});
35+
36+
it('should parse stale-while-revalidate from cache-control header', () => {
37+
const error = new GitBookAPIError(
38+
'Test error',
39+
new Response(null, {
40+
headers: {
41+
'cache-control': 'stale-while-revalidate=86400',
42+
},
43+
})
44+
);
45+
46+
const result = extractCacheControl(error);
47+
expect(result).toEqual({
48+
maxAge: undefined,
49+
staleWhileRevalidate: 86400,
50+
});
51+
});
52+
53+
it('should parse both max-age and stale-while-revalidate', () => {
54+
const error = new GitBookAPIError(
55+
'Test error',
56+
new Response(null, {
57+
headers: {
58+
'cache-control': 'max-age=3600, stale-while-revalidate=86400',
59+
},
60+
})
61+
);
62+
63+
const result = extractCacheControl(error);
64+
expect(result).toEqual({
65+
maxAge: 3600,
66+
staleWhileRevalidate: 86400,
67+
});
68+
});
69+
70+
it('should return undefined for maxAge when it is 0', () => {
71+
const error = new GitBookAPIError(
72+
'Test error',
73+
new Response(null, {
74+
headers: {
75+
'cache-control': 'max-age=0, stale-while-revalidate=86400',
76+
},
77+
})
78+
);
79+
80+
const result = extractCacheControl(error);
81+
expect(result).toEqual({
82+
maxAge: undefined,
83+
staleWhileRevalidate: 86400,
84+
});
85+
});
86+
87+
it('should handle complex cache-control header with multiple directives', () => {
88+
const error = new GitBookAPIError(
89+
'Test error',
90+
new Response(null, {
91+
headers: {
92+
'cache-control':
93+
'public, max-age=3600, must-revalidate, stale-while-revalidate=86400',
94+
},
95+
})
96+
);
97+
98+
const result = extractCacheControl(error);
99+
expect(result).toEqual({
100+
maxAge: 3600,
101+
staleWhileRevalidate: 86400,
102+
});
103+
});
104+
105+
it('should return undefined when cache-control has no parseable values', () => {
106+
const error = new GitBookAPIError(
107+
'Test error',
108+
new Response(null, {
109+
headers: {
110+
'cache-control': 'no-cache, no-store',
111+
},
112+
})
113+
);
114+
115+
const result = extractCacheControl(error);
116+
expect(result).toEqual({
117+
maxAge: undefined,
118+
staleWhileRevalidate: undefined,
119+
});
120+
});
121+
});

packages/gitbook/src/lib/data/errors.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { GitBookAPIError } from '@gitbook/api';
22
import { unstable_cacheLife as cacheLife } from 'next/cache';
33
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
44

5+
import parseCacheControl from 'parse-cache-control';
6+
57
export class DataFetcherError extends Error {
68
constructor(
79
message: string,
@@ -98,10 +100,22 @@ export async function wrapCacheDataFetcherError<T>(
98100
fn: () => Promise<T>
99101
): Promise<DataFetcherResponse<T>> {
100102
const result = await wrapDataFetcherError(fn);
101-
if (result.error && result.error.code >= 500) {
102-
// We don't want to cache errors for too long.
103-
// as the API might
104-
cacheLife('minutes');
103+
if (result.error) {
104+
const cacheValue = result.error.cache;
105+
// We only want to cache 404 errors for "long", because that's an "expected" error.
106+
if (result.error.code === 404) {
107+
cacheLife({
108+
stale: 60,
109+
revalidate: cacheValue?.maxAge ?? 60 * 60, // 1 hour
110+
expire: cacheValue?.staleWhileRevalidate ?? 60 * 60 * 24, // 1 day
111+
});
112+
} else {
113+
cacheLife({
114+
stale: 60, // This one is only for the client
115+
revalidate: cacheValue?.maxAge ?? 30, // we don't want to cache it for too long, but at least 30 seconds to avoid hammering the API
116+
expire: cacheValue?.staleWhileRevalidate ?? 90, // we want to revalidate this error after 90 seconds for sure
117+
});
118+
}
105119
}
106120
return result;
107121
}
@@ -134,6 +148,40 @@ export function ignoreDataFetcherErrors<T>(
134148
return response;
135149
}
136150

151+
/**
152+
* Extract cache control information from a GitBookAPIError.
153+
* If the error does not have a response or no cache-control, it returns undefined.
154+
*/
155+
export function extractCacheControl(error: GitBookAPIError) {
156+
try {
157+
if (!error.response) {
158+
return undefined;
159+
}
160+
161+
const cacheControl = error.response.headers.get('cache-control');
162+
if (!cacheControl) {
163+
return undefined;
164+
}
165+
const parsed = parseCacheControl(cacheControl);
166+
167+
//parseCacheControl does not support stale-while-revalidate, so we need to parse it manually
168+
const staleWhileRevalidateMatch = cacheControl.match(/stale-while-revalidate=(\d+)/i);
169+
170+
const maxAge = parsed?.['max-age'];
171+
const staleWhileRevalidate = staleWhileRevalidateMatch
172+
? Number.parseInt(staleWhileRevalidateMatch[1], 10)
173+
: undefined;
174+
175+
return {
176+
// If maxAge is 0, we want to apply the default, not 0
177+
maxAge: maxAge === 0 ? undefined : maxAge,
178+
staleWhileRevalidate,
179+
};
180+
} catch {
181+
return undefined;
182+
}
183+
}
184+
137185
/**
138186
* Get a data fetcher exposable error from a JS error.
139187
*/
@@ -142,10 +190,12 @@ export function getExposableError(error: Error): DataFetcherErrorData {
142190
if (error.code >= 500) {
143191
throw error;
144192
}
193+
const cache = extractCacheControl(error);
145194

146195
return {
147196
code: error.code,
148197
message: error.errorMessage,
198+
cache,
149199
};
150200
}
151201

packages/gitbook/src/lib/data/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import type * as api from '@gitbook/api';
33
export type DataFetcherErrorData = {
44
code: number;
55
message: string;
6+
cache?: {
7+
maxAge?: number;
8+
staleWhileRevalidate?: number;
9+
};
610
};
711

812
export type DataFetcherResponse<T> =

0 commit comments

Comments
 (0)