Skip to content

Commit

Permalink
feat: errors handling (#3320)
Browse files Browse the repository at this point in the history
  • Loading branch information
stepan662 authored Mar 14, 2024
1 parent 457db47 commit 249c4c4
Show file tree
Hide file tree
Showing 12 changed files with 457 additions and 51 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"private": true,
"scripts": {
"build": "turbo run build --cache-dir='.turbo'",
"test": "turbo run test --cache-dir='.turbo' && npm run testRoot",
"test": "turbo run test --cache-dir='.turbo' -- --runInBand && npm run testRoot -- --runInBand",
"testRoot": "jest",
"eslint": "eslint --ext .ts --ext .tsx --max-warnings 0 .",
"develop": "turbo run develop --parallel --include-dependencies --cache-dir='.turbo'",
Expand Down
48 changes: 30 additions & 18 deletions packages/core/src/Controller/Cache/Cache.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import {
CacheDescriptor,
CacheDescriptorInternal,
CacheDescriptorWithKey,
NsFallback,
TranslationsFlat,
TranslationValue,
TreeTranslationsData,
BackendGetRecordInternal,
RecordFetchError,
} from '../../types';
import { getFallbackArray, unique } from '../../helpers';
import { EventEmitterInstance } from '../Events/EventEmitter';
import { getFallbackArray, isPromise, unique } from '../../helpers';
import { TolgeeStaticData } from '../State/initState';
import { ValueObserverInstance } from '../ValueObserver';

import { decodeCacheKey, encodeCacheKey, flattenTranslations } from './helpers';
import { EventsInstance } from '../Events/Events';

type CacheAsyncRequests = Map<
string,
Expand All @@ -28,7 +28,7 @@ type CacheRecord = {
type StateCache = Map<string, CacheRecord>;

export function Cache(
onCacheChange: EventEmitterInstance<CacheDescriptorWithKey>,
events: EventsInstance,
backendGetRecord: BackendGetRecordInternal,
backendGetDevRecord: BackendGetRecordInternal,
withDefaultNs: (descriptor: CacheDescriptor) => CacheDescriptorInternal,
Expand All @@ -51,48 +51,60 @@ export function Cache(
data: flattenTranslations(data),
version: recordVersion,
});
onCacheChange.emit(descriptor);
events.onCacheChange.emit(descriptor);
}

/**
* Fetches production data
*/
function fetchProd(keyObject: CacheDescriptorInternal) {
let dataPromise = undefined as
let dataOrPromise = undefined as
| Promise<TreeTranslationsData | undefined>
| undefined;
if (!dataPromise) {
if (!dataOrPromise) {
const staticDataValue = staticData[encodeCacheKey(keyObject)];
if (typeof staticDataValue === 'function') {
dataPromise = staticDataValue();
dataOrPromise = staticDataValue();
}
}

if (!dataPromise) {
dataPromise = backendGetRecord(keyObject);
if (!dataOrPromise) {
dataOrPromise = backendGetRecord(keyObject);
}

return dataPromise;
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;
});
} else {
return dataOrPromise;
}
}

function fetchData(keyObject: CacheDescriptorInternal, isDev: boolean) {
let dataPromise = undefined as
let dataOrPromise = undefined as
| Promise<TreeTranslationsData | undefined>
| undefined;
if (isDev) {
dataPromise = backendGetDevRecord(keyObject)?.catch(() => {
dataOrPromise = backendGetDevRecord(keyObject)?.catch((e) => {
const error = new RecordFetchError(keyObject, e, true);
events.onError.emit(error);
// eslint-disable-next-line no-console
console.warn(`Tolgee: Failed to fetch data from dev backend`);
console.warn(error);
// fallback to prod fetch if dev fails
return fetchProd(keyObject);
});
}

if (!dataPromise) {
dataPromise = fetchProd(keyObject);
if (!dataOrPromise) {
dataOrPromise = fetchProd(keyObject);
}

return dataPromise;
return dataOrPromise;
}

const self = Object.freeze({
Expand Down Expand Up @@ -175,7 +187,7 @@ export function Cache(
) {
const record = cache.get(encodeCacheKey(descriptor))?.data;
record?.set(key, value);
onCacheChange.emit({ ...descriptor, key });
events.onCacheChange.emit({ ...descriptor, key });
},

isFetching(ns?: NsFallback) {
Expand Down
15 changes: 4 additions & 11 deletions packages/core/src/Controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
TFnType,
NsType,
KeyAndNamespacesInternal,
TranslationDescriptor,
} from '../types';
import { Cache } from './Cache/Cache';
import { getFallbackArray } from '../helpers';
Expand Down Expand Up @@ -47,11 +46,11 @@ export function Controller({ options }: StateServiceProps) {
getTranslationNs,
getTranslation,
changeTranslation,
onPermanentChange
events
);

const cache = Cache(
events.onCacheChange,
events,
pluginService.getBackendRecord,
pluginService.getBackendDevRecord,
state.withDefaultNs,
Expand Down Expand Up @@ -110,13 +109,6 @@ export function Controller({ options }: StateServiceProps) {
};
}

function onPermanentChange(props: TranslationDescriptor) {
events.onPermanentChange.emit({
key: props.key,
namespace: props.namespace,
});
}

function init(options: Partial<TolgeeOptions>) {
state.init(options);
cache.addStaticData(state.getInitialOptions().staticData);
Expand Down Expand Up @@ -182,6 +174,7 @@ export function Controller({ options }: StateServiceProps) {
return;
}
const languageOrPromise = pluginService.getInitialLanguage();

return valueOrPromise(languageOrPromise, (lang) => {
const language =
(lang as string | undefined) ||
Expand Down Expand Up @@ -231,7 +224,7 @@ export function Controller({ options }: StateServiceProps) {
// there might be parallel language change
// we only want to apply latest
state.setLanguage(language);
pluginService.setStoredLanguage(language);
await pluginService.setStoredLanguage(language);
}
},

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/Controller/Events/Events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { EventEmitter } from './EventEmitter';
import { EventEmitterSelective } from './EventEmitterSelective';
import {
CacheDescriptorWithKey,
TolgeeError,
TolgeeOn,
TranslationDescriptor,
} from '../../types';
Expand All @@ -26,6 +27,7 @@ export function Events(
onCacheChange: EventEmitter<CacheDescriptorWithKey>(isActive),
onUpdate: EventEmitterSelective(isActive, getFallbackNs, getDefaultNs),
onPermanentChange: EventEmitter<TranslationDescriptor>(isActive),
onError: EventEmitter<TolgeeError>(isActive),
setEmitterActive(active: boolean) {
emitterActive = active;
},
Expand All @@ -49,6 +51,8 @@ export function Events(
return self.onUpdate.listen(handler as any);
case 'permanentChange':
return self.onPermanentChange.listen(handler as any);
case 'error':
return self.onError.listen(handler as any);
}
}) as TolgeeOn,
});
Expand Down
52 changes: 33 additions & 19 deletions packages/core/src/Controller/Plugins/Plugins.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { getErrorMessage, isPromise, valueOrPromise } from '../../helpers';
import {
getErrorMessage,
valueOrPromise,
handleRegularOrAsyncErr,
} from '../../helpers';
import {
BackendDevMiddleware,
BackendMiddleware,
Expand All @@ -22,8 +26,10 @@ import {
FormatErrorHandler,
FindPositionsInterface,
BackendGetRecordInternal,
TranslationDescriptor,
LanguageStorageError,
LanguageDetectorError,
} from '../../types';
import { EventsInstance } from '../Events/Events';
import { DEFAULT_FORMAT_ERROR } from '../State/initState';

export function Plugins(
Expand All @@ -34,7 +40,7 @@ export function Plugins(
getTranslationNs: (props: KeyAndNamespacesInternal) => string[],
getTranslation: (props: KeyAndNamespacesInternal) => string | undefined,
changeTranslation: ChangeTranslationInterface,
onPermanentChange: (props: TranslationDescriptor) => void
events: EventsInstance
) {
const plugins = {
ui: undefined as UiMiddleware | undefined,
Expand Down Expand Up @@ -125,17 +131,30 @@ export function Plugins(
instances.languageDetector = detector;
}

function storageLoadLanguage() {
return handleRegularOrAsyncErr(
events.onError,
(e) => new LanguageStorageError('Tolgee: Failed to load language', e),
() => instances.languageStorage?.getLanguage(getCommonProps())
);
}

function detectLanguage() {
if (!instances.languageDetector) {
return undefined;
}

const availableLanguages = getAvailableLanguages()!;

return instances.languageDetector.getLanguage({
availableLanguages,
...getCommonProps(),
});
return handleRegularOrAsyncErr(
events.onError,
(e) => new LanguageDetectorError('Tolgee: Failed to detect language', e),
() =>
instances.languageDetector?.getLanguage({
availableLanguages,
...getCommonProps(),
})
);
}

function addBackend(backend: BackendMiddleware | undefined) {
Expand Down Expand Up @@ -177,7 +196,7 @@ export function Plugins(
highlight: self.highlight,
changeTranslation,
findPositions,
onPermanentChange,
onPermanentChange: (data) => events.onPermanentChange.emit(data),
});

instances.observer?.run({
Expand All @@ -199,9 +218,7 @@ export function Plugins(

getInitialLanguage() {
const availableLanguages = getAvailableLanguages();
const languageOrPromise = instances.languageStorage?.getLanguage(
getCommonProps()
);
const languageOrPromise = storageLoadLanguage();

return valueOrPromise(languageOrPromise, (language) => {
if (
Expand All @@ -215,7 +232,11 @@ export function Plugins(
},

setStoredLanguage(language: string) {
instances.languageStorage?.setLanguage(language, getCommonProps());
return handleRegularOrAsyncErr(
events.onError,
(e) => new LanguageStorageError('Tolgee: Failed to store language', e),
() => instances.languageStorage?.setLanguage(language, getCommonProps())
);
},

getDevBackend() {
Expand All @@ -229,13 +250,6 @@ export function Plugins(
namespace,
...getCommonProps(),
});
if (isPromise(data)) {
return data?.catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
return {};
});
}
if (data !== undefined) {
return data;
}
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/__test/errors.detector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/* eslint-disable no-console */
import { TolgeeCore } from '../TolgeeCore';
import { LanguageDetectorError, TolgeePlugin } from '../types';

const failingDetector = (async: boolean): TolgeePlugin => {
return (tolgee, tools) => {
tools.setLanguageDetector({
async getLanguage() {
if (async) {
return Promise.reject(new Error('failed to fetch'));
} else {
throw new Error('failed to fetch');
}
},
});
return tolgee;
};
};

describe('language detector errors', () => {
beforeEach(() => {
console.warn = jest.fn();
console.error = jest.fn();
});

it('emitts error when detector fails async', async () => {
const errorHandler = jest.fn();
const tolgee = TolgeeCore()
.use(failingDetector(true))
.init({ availableLanguages: ['en', 'cs'], defaultLanguage: 'en' });
tolgee.on('error', errorHandler);
await expect(() => tolgee.run()).rejects.toThrow(LanguageDetectorError);
const firstArgument = errorHandler.mock.calls[0][0]
.value as LanguageDetectorError;
expect(firstArgument).toBeInstanceOf(LanguageDetectorError);
expect(firstArgument).toBeInstanceOf(Error);
expect(firstArgument).toHaveProperty('name', 'LanguageDetectorError');
expect(firstArgument).toHaveProperty(
'message',
'Tolgee: Failed to detect language'
);
expect(console.warn).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(1);
});

it('emitts error when detector fails sync', async () => {
const errorHandler = jest.fn();
const tolgee = TolgeeCore()
.use(failingDetector(false))
.init({ availableLanguages: ['en', 'cs'], defaultLanguage: 'en' });
tolgee.on('error', errorHandler);
await expect(() => tolgee.run()).rejects.toThrow(LanguageDetectorError);
const firstArgument = errorHandler.mock.calls[0][0]
.value as LanguageDetectorError;
expect(firstArgument).toBeInstanceOf(LanguageDetectorError);
expect(firstArgument).toHaveProperty('name', 'LanguageDetectorError');
expect(firstArgument).toHaveProperty(
'message',
'Tolgee: Failed to detect language'
);
expect(console.warn).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(1);
});
});
Loading

0 comments on commit 249c4c4

Please sign in to comment.