Skip to content

Commit

Permalink
feat: ability to have fallbacks and timeouts with backend plugin (#3385)
Browse files Browse the repository at this point in the history
  • Loading branch information
stepan662 authored Nov 1, 2024
1 parent 08c6c0d commit 2406876
Show file tree
Hide file tree
Showing 11 changed files with 332 additions and 38 deletions.
37 changes: 20 additions & 17 deletions packages/core/src/Controller/Cache/Cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,28 +58,31 @@ export function Cache(
* Fetches production data
*/
async function fetchProd(keyObject: CacheDescriptorInternal) {
let dataOrPromise = undefined as
| Promise<TreeTranslationsData | undefined>
| undefined;
const staticDataValue = staticData[encodeCacheKey(keyObject)];
if (typeof staticDataValue === 'function') {
dataOrPromise = staticDataValue();
function handleError(e: any) {
const error = new RecordFetchError(keyObject, e);
events.onError.emit(error);
// eslint-disable-next-line no-console
console.error(error);
throw error;
}

if (!dataOrPromise) {
dataOrPromise = backendGetRecord(keyObject);
const dataFromBackend = backendGetRecord(keyObject);
if (isPromise(dataFromBackend)) {
const result = await dataFromBackend.catch(handleError);
if (result !== undefined) {
return result;
}
}

if (isPromise(dataOrPromise)) {
return dataOrPromise?.catch((e) => {
const error = new RecordFetchError(keyObject, e);
events.onError.emit(error);
// eslint-disable-next-line no-console
console.error(error);
throw error;
});
const staticDataValue = staticData[encodeCacheKey(keyObject)];
if (typeof staticDataValue === 'function') {
try {
return await staticDataValue();
} catch (e) {
handleError(e);
}
} else {
return dataOrPromise;
return staticDataValue;
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/TolgeeCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function createTolgee(options: TolgeeOptions) {
loadRecord: controller.loadRecord,

/**
*
* Prefill static data
*/
addStaticData: controller.addStaticData,

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/__test/languages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('language changes', () => {
'cs:fallback': loadNs,
},
});
tolgee.run();
await tolgee.run();
expect(loadNs).toBeCalledTimes(2);
await tolgee.changeLanguage('cs');
expect(loadNs).toBeCalledTimes(4);
Expand Down
124 changes: 124 additions & 0 deletions packages/web/src/package/BackendFetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { createBackendFetch } from './BackendFetch';
import { createFetchingUtility } from './__test__/fetchingUtillity';

describe('backend fetch', () => {
let f: ReturnType<typeof createFetchingUtility>;

beforeEach(() => {
f = createFetchingUtility();
});

it('calls fetch with correct params', () => {
const plugin = createBackendFetch();
plugin.getRecord({ fetch: f.fetchMock, language: 'de' });
expect(f.fetchMock).toHaveBeenCalledWith(
'/i18n/de.json',
expect.objectContaining({
headers: { Accept: 'application/json' },
})
);
});

it('calls fetch with custom prefix', () => {
const plugin = createBackendFetch({ prefix: 'http://test.com/test' });
plugin.getRecord({ fetch: f.fetchMock, language: 'de', namespace: 'ns' });
expect(f.fetchMock).toHaveBeenCalledWith(
'http://test.com/test/ns/de.json',
expect.objectContaining({
headers: { Accept: 'application/json' },
})
);
});

it('handles extra slash', () => {
const plugin = createBackendFetch({ prefix: 'http://test.com/test/' });
plugin.getRecord({ fetch: f.fetchMock, language: 'de' });
expect(f.fetchMock).toHaveBeenCalledWith(
'http://test.com/test/de.json',
expect.objectContaining({
headers: { Accept: 'application/json' },
})
);
});

it('adds headers', () => {
const plugin = createBackendFetch({
prefix: 'http://test.com/test/',
headers: { Authorization: 'test' },
});
plugin.getRecord({ fetch: f.fetchMock, language: 'de' });
expect(f.fetchMock).toHaveBeenCalledWith(
'http://test.com/test/de.json',
expect.objectContaining({
headers: { Accept: 'application/json', Authorization: 'test' },
})
);
});

it('passes additional properties', () => {
const plugin = createBackendFetch({
prefix: 'http://test.com/test/',
cache: 'no-cache',
});
plugin.getRecord({ fetch: f.fetchMock, language: 'de' });
expect(f.fetchMock).toHaveBeenCalledWith(
'http://test.com/test/de.json',
expect.objectContaining({
headers: { Accept: 'application/json' },
cache: 'no-cache',
})
);
});

it('fails with a timeout', async () => {
const plugin = createBackendFetch({
prefix: 'http://test.com/test/',
timeout: 5,
});
await expect(
plugin.getRecord({ fetch: f.infiniteFetch, language: 'de' })
).rejects.toHaveProperty(
'message',
'TIMEOUT: http://test.com/test/de.json'
);

expect(f.infiniteFetch).toHaveBeenCalledWith(
'http://test.com/test/de.json',
expect.objectContaining({
headers: { Accept: 'application/json' },
})
);
expect(f.signalHandler).toHaveBeenCalledWith('Aborted with signal');
});

it('throws the original error', async () => {
const plugin = createBackendFetch({
prefix: 'http://test.com/test/',
});
expect(
plugin.getRecord({ fetch: f.failingFetch, language: 'de' })
).rejects.toHaveProperty('message', 'Fetch failed');
});

it('returns undefined when `fallbackOnFail`', async () => {
const plugin = createBackendFetch({
prefix: 'http://test.com/test/',
fallbackOnFail: true,
});
expect(
await plugin.getRecord({ fetch: f.failingFetch, language: 'de' })
).toEqual(undefined);
});

it('returns undefined when `fallbackOnFail` and timeout', async () => {
const plugin = createBackendFetch({
prefix: 'http://test.com/test/',
fallbackOnFail: true,
timeout: 5,
});
expect(
await plugin.getRecord({ fetch: f.infiniteFetch, language: 'de' })
).toEqual(undefined);
expect(f.signalHandler).toHaveBeenCalledWith('Aborted with signal');
});
});
84 changes: 69 additions & 15 deletions packages/web/src/package/BackendFetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
import type { BackendMiddleware, TolgeePlugin } from '@tolgee/core';
import type { BackendMiddleware, FetchFn, TolgeePlugin } from '@tolgee/core';
import { GetPath, BackendOptions } from './types';

const fetchWithTimeout = (
fetch: FetchFn,
url: string,
ms: number | undefined,
{ signal, ...options }: RequestInit
) => {
const controller = new AbortController();
return new Promise<Response>((_resolve, _reject) => {
const promise = fetch(url, { signal: controller.signal, ...options });
let done = false;
function resolve(data) {
!done && _resolve(data);
done = true;
}
function reject(data) {
!done && _reject(data);
done = true;
}
function rejectWithTimout() {
const error = new Error(`TIMEOUT: ${url}`);
controller.abort(error);
reject(error);
}
if (signal) {
signal.addEventListener('abort', rejectWithTimout);
}
if (ms !== undefined) {
const timeout = setTimeout(rejectWithTimout, ms);
promise.finally(() => clearTimeout(timeout));
}
promise.catch(reject).then(resolve);
});
};

function trimSlashes(path: string) {
if (path.endsWith('/')) {
return path.slice(0, -1);
Expand All @@ -27,33 +61,53 @@ const DEFAULT_OPTIONS = {
headers: {
Accept: 'application/json',
},
timeout: undefined,
fallbackOnFail: false,
};

function createBackendFetch(
export function createBackendFetch(
options?: Partial<BackendOptions>
): BackendMiddleware {
const { prefix, getPath, getData, headers, ...fetchOptions }: BackendOptions =
{
...DEFAULT_OPTIONS,
...options,
headers: {
...DEFAULT_OPTIONS.headers,
...options?.headers,
},
};
const {
prefix,
getPath,
getData,
headers,
timeout,
fallbackOnFail,
...fetchOptions
}: BackendOptions = {
...DEFAULT_OPTIONS,
...options,
headers: {
...DEFAULT_OPTIONS.headers,
...options?.headers,
},
};
return {
getRecord({ namespace, language, fetch }) {
async getRecord({ namespace, language, fetch }) {
const path = getPath({
namespace,
language,
prefix,
});
return fetch(path, { headers, ...fetchOptions }).then((r) => {

try {
const r = await fetchWithTimeout(fetch, path, timeout, {
headers,
...fetchOptions,
});
if (!r.ok) {
throw new Error(`${r.url} ${r.status}`);
}
return getData(r);
});
return await getData(r);
} catch (e) {
if (fallbackOnFail) {
return undefined;
} else {
throw e;
}
}
},
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/package/DevBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ function createDevBackend(): BackendDevMiddleware {
'X-API-Key': apiKey || '',
'Content-Type': 'application/json',
},
// @ts-ignore - tell next.js to not use cache
next: { revalidate: 0 },
}).then((r) => {
if (r.ok) {
return r.json().then((data) => data[language]);
Expand Down
82 changes: 82 additions & 0 deletions packages/web/src/package/__test__/fetch.fallbacks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { TolgeeCore } from '@tolgee/core';
import { createFetchingUtility } from './fetchingUtillity';
import { BackendFetch } from '../BackendFetch';

describe('tolgee with fallback backend fetch', () => {
let f: ReturnType<typeof createFetchingUtility>;

beforeEach(() => {
f = createFetchingUtility();
});

it('fallback works with backend fetch', async () => {
// eslint-disable-next-line no-console
console.error = jest.fn();
const tolgee = TolgeeCore()
.use(BackendFetch({ prefix: '1', timeout: 2, fallbackOnFail: true }))
.use(BackendFetch({ prefix: '2', timeout: 2, fallbackOnFail: true }))
.use(BackendFetch({ prefix: '3', timeout: 2, fallbackOnFail: false }))
.init({
language: 'en',
availableLanguages: ['en'],
fetch: f.infiniteFetch,
});
await expect(tolgee.loadRecord({ language: 'en' })).rejects.toHaveProperty(
'message',
'Tolgee: Failed to fetch record for "en"'
);
expect(f.infiniteFetch).toHaveBeenCalledTimes(3);
});

it('fallback works when all backend fetch plugins fail', async () => {
const tolgee = TolgeeCore()
.use(BackendFetch({ prefix: '1', timeout: 2, fallbackOnFail: true }))
.use(BackendFetch({ prefix: '2', timeout: 2, fallbackOnFail: true }))
.use(BackendFetch({ prefix: '3', timeout: 2, fallbackOnFail: true }))
.init({
language: 'en',
availableLanguages: ['en'],
fetch: f.infiniteFetch,
});
await expect(tolgee.loadRecord({ language: 'en' })).resolves.toEqual(
new Map()
);
expect(f.infiniteFetch).toHaveBeenCalledTimes(3);
});

it('fallback works with static data', async () => {
const tolgee = TolgeeCore()
.use(BackendFetch({ prefix: '1', timeout: 2, fallbackOnFail: true }))
.use(BackendFetch({ prefix: '2', timeout: 2, fallbackOnFail: true }))
.init({
language: 'en',
availableLanguages: ['en'],
fetch: f.infiniteFetch,
staticData: {
en: { test: 'test' },
},
});
await expect(tolgee.loadRecord({ language: 'en' })).resolves.toEqual(
new Map([['test', 'test']])
);
expect(f.infiniteFetch).toHaveBeenCalledTimes(2);
});

it('fallback works with dynamic static data', async () => {
const tolgee = TolgeeCore()
.use(BackendFetch({ prefix: '1', timeout: 2, fallbackOnFail: true }))
.use(BackendFetch({ prefix: '2', timeout: 2, fallbackOnFail: true }))
.init({
language: 'en',
availableLanguages: ['en'],
fetch: f.infiniteFetch,
staticData: {
en: () => Promise.resolve({ test: 'test' }),
},
});
await expect(tolgee.loadRecord({ language: 'en' })).resolves.toEqual(
new Map([['test', 'test']])
);
expect(f.infiniteFetch).toHaveBeenCalledTimes(2);
});
});
Loading

0 comments on commit 2406876

Please sign in to comment.