Skip to content

Commit 2406876

Browse files
authored
feat: ability to have fallbacks and timeouts with backend plugin (#3385)
1 parent 08c6c0d commit 2406876

11 files changed

Lines changed: 332 additions & 38 deletions

File tree

packages/core/src/Controller/Cache/Cache.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -58,28 +58,31 @@ export function Cache(
5858
* Fetches production data
5959
*/
6060
async function fetchProd(keyObject: CacheDescriptorInternal) {
61-
let dataOrPromise = undefined as
62-
| Promise<TreeTranslationsData | undefined>
63-
| undefined;
64-
const staticDataValue = staticData[encodeCacheKey(keyObject)];
65-
if (typeof staticDataValue === 'function') {
66-
dataOrPromise = staticDataValue();
61+
function handleError(e: any) {
62+
const error = new RecordFetchError(keyObject, e);
63+
events.onError.emit(error);
64+
// eslint-disable-next-line no-console
65+
console.error(error);
66+
throw error;
6767
}
6868

69-
if (!dataOrPromise) {
70-
dataOrPromise = backendGetRecord(keyObject);
69+
const dataFromBackend = backendGetRecord(keyObject);
70+
if (isPromise(dataFromBackend)) {
71+
const result = await dataFromBackend.catch(handleError);
72+
if (result !== undefined) {
73+
return result;
74+
}
7175
}
7276

73-
if (isPromise(dataOrPromise)) {
74-
return dataOrPromise?.catch((e) => {
75-
const error = new RecordFetchError(keyObject, e);
76-
events.onError.emit(error);
77-
// eslint-disable-next-line no-console
78-
console.error(error);
79-
throw error;
80-
});
77+
const staticDataValue = staticData[encodeCacheKey(keyObject)];
78+
if (typeof staticDataValue === 'function') {
79+
try {
80+
return await staticDataValue();
81+
} catch (e) {
82+
handleError(e);
83+
}
8184
} else {
82-
return dataOrPromise;
85+
return staticDataValue;
8386
}
8487
}
8588

packages/core/src/TolgeeCore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ function createTolgee(options: TolgeeOptions) {
107107
loadRecord: controller.loadRecord,
108108

109109
/**
110-
*
110+
* Prefill static data
111111
*/
112112
addStaticData: controller.addStaticData,
113113

packages/core/src/__test/languages.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('language changes', () => {
122122
'cs:fallback': loadNs,
123123
},
124124
});
125-
tolgee.run();
125+
await tolgee.run();
126126
expect(loadNs).toBeCalledTimes(2);
127127
await tolgee.changeLanguage('cs');
128128
expect(loadNs).toBeCalledTimes(4);
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { createBackendFetch } from './BackendFetch';
2+
import { createFetchingUtility } from './__test__/fetchingUtillity';
3+
4+
describe('backend fetch', () => {
5+
let f: ReturnType<typeof createFetchingUtility>;
6+
7+
beforeEach(() => {
8+
f = createFetchingUtility();
9+
});
10+
11+
it('calls fetch with correct params', () => {
12+
const plugin = createBackendFetch();
13+
plugin.getRecord({ fetch: f.fetchMock, language: 'de' });
14+
expect(f.fetchMock).toHaveBeenCalledWith(
15+
'/i18n/de.json',
16+
expect.objectContaining({
17+
headers: { Accept: 'application/json' },
18+
})
19+
);
20+
});
21+
22+
it('calls fetch with custom prefix', () => {
23+
const plugin = createBackendFetch({ prefix: 'http://test.com/test' });
24+
plugin.getRecord({ fetch: f.fetchMock, language: 'de', namespace: 'ns' });
25+
expect(f.fetchMock).toHaveBeenCalledWith(
26+
'http://test.com/test/ns/de.json',
27+
expect.objectContaining({
28+
headers: { Accept: 'application/json' },
29+
})
30+
);
31+
});
32+
33+
it('handles extra slash', () => {
34+
const plugin = createBackendFetch({ prefix: 'http://test.com/test/' });
35+
plugin.getRecord({ fetch: f.fetchMock, language: 'de' });
36+
expect(f.fetchMock).toHaveBeenCalledWith(
37+
'http://test.com/test/de.json',
38+
expect.objectContaining({
39+
headers: { Accept: 'application/json' },
40+
})
41+
);
42+
});
43+
44+
it('adds headers', () => {
45+
const plugin = createBackendFetch({
46+
prefix: 'http://test.com/test/',
47+
headers: { Authorization: 'test' },
48+
});
49+
plugin.getRecord({ fetch: f.fetchMock, language: 'de' });
50+
expect(f.fetchMock).toHaveBeenCalledWith(
51+
'http://test.com/test/de.json',
52+
expect.objectContaining({
53+
headers: { Accept: 'application/json', Authorization: 'test' },
54+
})
55+
);
56+
});
57+
58+
it('passes additional properties', () => {
59+
const plugin = createBackendFetch({
60+
prefix: 'http://test.com/test/',
61+
cache: 'no-cache',
62+
});
63+
plugin.getRecord({ fetch: f.fetchMock, language: 'de' });
64+
expect(f.fetchMock).toHaveBeenCalledWith(
65+
'http://test.com/test/de.json',
66+
expect.objectContaining({
67+
headers: { Accept: 'application/json' },
68+
cache: 'no-cache',
69+
})
70+
);
71+
});
72+
73+
it('fails with a timeout', async () => {
74+
const plugin = createBackendFetch({
75+
prefix: 'http://test.com/test/',
76+
timeout: 5,
77+
});
78+
await expect(
79+
plugin.getRecord({ fetch: f.infiniteFetch, language: 'de' })
80+
).rejects.toHaveProperty(
81+
'message',
82+
'TIMEOUT: http://test.com/test/de.json'
83+
);
84+
85+
expect(f.infiniteFetch).toHaveBeenCalledWith(
86+
'http://test.com/test/de.json',
87+
expect.objectContaining({
88+
headers: { Accept: 'application/json' },
89+
})
90+
);
91+
expect(f.signalHandler).toHaveBeenCalledWith('Aborted with signal');
92+
});
93+
94+
it('throws the original error', async () => {
95+
const plugin = createBackendFetch({
96+
prefix: 'http://test.com/test/',
97+
});
98+
expect(
99+
plugin.getRecord({ fetch: f.failingFetch, language: 'de' })
100+
).rejects.toHaveProperty('message', 'Fetch failed');
101+
});
102+
103+
it('returns undefined when `fallbackOnFail`', async () => {
104+
const plugin = createBackendFetch({
105+
prefix: 'http://test.com/test/',
106+
fallbackOnFail: true,
107+
});
108+
expect(
109+
await plugin.getRecord({ fetch: f.failingFetch, language: 'de' })
110+
).toEqual(undefined);
111+
});
112+
113+
it('returns undefined when `fallbackOnFail` and timeout', async () => {
114+
const plugin = createBackendFetch({
115+
prefix: 'http://test.com/test/',
116+
fallbackOnFail: true,
117+
timeout: 5,
118+
});
119+
expect(
120+
await plugin.getRecord({ fetch: f.infiniteFetch, language: 'de' })
121+
).toEqual(undefined);
122+
expect(f.signalHandler).toHaveBeenCalledWith('Aborted with signal');
123+
});
124+
});

packages/web/src/package/BackendFetch.ts

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
1-
import type { BackendMiddleware, TolgeePlugin } from '@tolgee/core';
1+
import type { BackendMiddleware, FetchFn, TolgeePlugin } from '@tolgee/core';
22
import { GetPath, BackendOptions } from './types';
33

4+
const fetchWithTimeout = (
5+
fetch: FetchFn,
6+
url: string,
7+
ms: number | undefined,
8+
{ signal, ...options }: RequestInit
9+
) => {
10+
const controller = new AbortController();
11+
return new Promise<Response>((_resolve, _reject) => {
12+
const promise = fetch(url, { signal: controller.signal, ...options });
13+
let done = false;
14+
function resolve(data) {
15+
!done && _resolve(data);
16+
done = true;
17+
}
18+
function reject(data) {
19+
!done && _reject(data);
20+
done = true;
21+
}
22+
function rejectWithTimout() {
23+
const error = new Error(`TIMEOUT: ${url}`);
24+
controller.abort(error);
25+
reject(error);
26+
}
27+
if (signal) {
28+
signal.addEventListener('abort', rejectWithTimout);
29+
}
30+
if (ms !== undefined) {
31+
const timeout = setTimeout(rejectWithTimout, ms);
32+
promise.finally(() => clearTimeout(timeout));
33+
}
34+
promise.catch(reject).then(resolve);
35+
});
36+
};
37+
438
function trimSlashes(path: string) {
539
if (path.endsWith('/')) {
640
return path.slice(0, -1);
@@ -27,33 +61,53 @@ const DEFAULT_OPTIONS = {
2761
headers: {
2862
Accept: 'application/json',
2963
},
64+
timeout: undefined,
65+
fallbackOnFail: false,
3066
};
3167

32-
function createBackendFetch(
68+
export function createBackendFetch(
3369
options?: Partial<BackendOptions>
3470
): BackendMiddleware {
35-
const { prefix, getPath, getData, headers, ...fetchOptions }: BackendOptions =
36-
{
37-
...DEFAULT_OPTIONS,
38-
...options,
39-
headers: {
40-
...DEFAULT_OPTIONS.headers,
41-
...options?.headers,
42-
},
43-
};
71+
const {
72+
prefix,
73+
getPath,
74+
getData,
75+
headers,
76+
timeout,
77+
fallbackOnFail,
78+
...fetchOptions
79+
}: BackendOptions = {
80+
...DEFAULT_OPTIONS,
81+
...options,
82+
headers: {
83+
...DEFAULT_OPTIONS.headers,
84+
...options?.headers,
85+
},
86+
};
4487
return {
45-
getRecord({ namespace, language, fetch }) {
88+
async getRecord({ namespace, language, fetch }) {
4689
const path = getPath({
4790
namespace,
4891
language,
4992
prefix,
5093
});
51-
return fetch(path, { headers, ...fetchOptions }).then((r) => {
94+
95+
try {
96+
const r = await fetchWithTimeout(fetch, path, timeout, {
97+
headers,
98+
...fetchOptions,
99+
});
52100
if (!r.ok) {
53101
throw new Error(`${r.url} ${r.status}`);
54102
}
55-
return getData(r);
56-
});
103+
return await getData(r);
104+
} catch (e) {
105+
if (fallbackOnFail) {
106+
return undefined;
107+
} else {
108+
throw e;
109+
}
110+
}
57111
},
58112
};
59113
}

packages/web/src/package/DevBackend.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ function createDevBackend(): BackendDevMiddleware {
2222
'X-API-Key': apiKey || '',
2323
'Content-Type': 'application/json',
2424
},
25+
// @ts-ignore - tell next.js to not use cache
26+
next: { revalidate: 0 },
2527
}).then((r) => {
2628
if (r.ok) {
2729
return r.json().then((data) => data[language]);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { TolgeeCore } from '@tolgee/core';
2+
import { createFetchingUtility } from './fetchingUtillity';
3+
import { BackendFetch } from '../BackendFetch';
4+
5+
describe('tolgee with fallback backend fetch', () => {
6+
let f: ReturnType<typeof createFetchingUtility>;
7+
8+
beforeEach(() => {
9+
f = createFetchingUtility();
10+
});
11+
12+
it('fallback works with backend fetch', async () => {
13+
// eslint-disable-next-line no-console
14+
console.error = jest.fn();
15+
const tolgee = TolgeeCore()
16+
.use(BackendFetch({ prefix: '1', timeout: 2, fallbackOnFail: true }))
17+
.use(BackendFetch({ prefix: '2', timeout: 2, fallbackOnFail: true }))
18+
.use(BackendFetch({ prefix: '3', timeout: 2, fallbackOnFail: false }))
19+
.init({
20+
language: 'en',
21+
availableLanguages: ['en'],
22+
fetch: f.infiniteFetch,
23+
});
24+
await expect(tolgee.loadRecord({ language: 'en' })).rejects.toHaveProperty(
25+
'message',
26+
'Tolgee: Failed to fetch record for "en"'
27+
);
28+
expect(f.infiniteFetch).toHaveBeenCalledTimes(3);
29+
});
30+
31+
it('fallback works when all backend fetch plugins fail', async () => {
32+
const tolgee = TolgeeCore()
33+
.use(BackendFetch({ prefix: '1', timeout: 2, fallbackOnFail: true }))
34+
.use(BackendFetch({ prefix: '2', timeout: 2, fallbackOnFail: true }))
35+
.use(BackendFetch({ prefix: '3', timeout: 2, fallbackOnFail: true }))
36+
.init({
37+
language: 'en',
38+
availableLanguages: ['en'],
39+
fetch: f.infiniteFetch,
40+
});
41+
await expect(tolgee.loadRecord({ language: 'en' })).resolves.toEqual(
42+
new Map()
43+
);
44+
expect(f.infiniteFetch).toHaveBeenCalledTimes(3);
45+
});
46+
47+
it('fallback works with static data', async () => {
48+
const tolgee = TolgeeCore()
49+
.use(BackendFetch({ prefix: '1', timeout: 2, fallbackOnFail: true }))
50+
.use(BackendFetch({ prefix: '2', timeout: 2, fallbackOnFail: true }))
51+
.init({
52+
language: 'en',
53+
availableLanguages: ['en'],
54+
fetch: f.infiniteFetch,
55+
staticData: {
56+
en: { test: 'test' },
57+
},
58+
});
59+
await expect(tolgee.loadRecord({ language: 'en' })).resolves.toEqual(
60+
new Map([['test', 'test']])
61+
);
62+
expect(f.infiniteFetch).toHaveBeenCalledTimes(2);
63+
});
64+
65+
it('fallback works with dynamic static data', async () => {
66+
const tolgee = TolgeeCore()
67+
.use(BackendFetch({ prefix: '1', timeout: 2, fallbackOnFail: true }))
68+
.use(BackendFetch({ prefix: '2', timeout: 2, fallbackOnFail: true }))
69+
.init({
70+
language: 'en',
71+
availableLanguages: ['en'],
72+
fetch: f.infiniteFetch,
73+
staticData: {
74+
en: () => Promise.resolve({ test: 'test' }),
75+
},
76+
});
77+
await expect(tolgee.loadRecord({ language: 'en' })).resolves.toEqual(
78+
new Map([['test', 'test']])
79+
);
80+
expect(f.infiniteFetch).toHaveBeenCalledTimes(2);
81+
});
82+
});

0 commit comments

Comments
 (0)