Last active
April 14, 2023 16:44
-
-
Save zerkalica/f2507ddafcc7277e3be435dcc80082ff to your computer and use it in GitHub Desktop.
errors.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
declare class AggregateError extends Error { | |
readonly errors: (Error | object | string)[]; | |
constructor(errors: readonly (Error | object | string)[], message: string, options?: { cause: unknown }); | |
} | |
function createAggregateErrorStub(): typeof AggregateError { | |
return class AggregateErrorStub extends Error { | |
constructor(readonly errors: (Error | object | string)[], message: string, options?: { cause: unknown }) { | |
// @ts-ignore | |
super(message, options); | |
this.name = 'AggregateError'; | |
this.errors = errors; | |
} | |
}; | |
} | |
export const AggregateErrorSafe = typeof AggregateError === 'undefined' ? createAggregateErrorStub() : AggregateError; | |
export type CauseOptBase = { | |
__typename?: string | undefined; | |
label?: string | null; | |
message?: string | null; | |
code?: string | null; | |
statusCode?: string; | |
}; | |
function isPresent<T>(t: T | undefined | null | void | false): t is T { | |
return t !== undefined && t !== null && t !== false; | |
} | |
type ErrorEx<CauseOpt> = { | |
code?: string | null; | |
statusCode?: string | null; | |
cause: CauseOpt; | |
}; | |
/** | |
* Библиотека нормализации ошибок, построенная на исключениях. | |
* Пытается оставлять первоначальный инстанс ошибки, дописывая контекст в cause по мере всплытия. | |
* Множество ошибок запаковываются в AggregateError. | |
* Также может извлекать Union ошибок из Error.cause | |
* | |
@example | |
```ts | |
type ErrorDto = { | |
__typename: 'a', | |
a: string | |
} | { | |
__typename: 'b' | |
b: number; | |
} | |
class MyError extends ErrorBase<ErrorDto> {} | |
try { | |
const {data, errors} = gqlFetch() as {data: unknown, errors: ErrorDto[]} | |
if (errors.length || ! data) { | |
throw MyError.ensure([ | |
...errors, | |
! data && { | |
__typename: 'a', | |
a: '123' | |
} | |
]) | |
} | |
} catch (e) { | |
const causes = MyError.causes(e) | |
causes.map(cause => cause.__typename === 'a' && cause.a) | |
} | |
``` | |
*/ | |
export class ErrorBase<CauseOpt extends CauseOptBase = CauseOptBase> extends Error { | |
cause = {} as CauseOpt; | |
static make<Instance>(this: { new (): Instance }, config: Partial<Instance> = {}): Instance { | |
return Object.assign(new this(), config); | |
} | |
static ensure<Instance extends { cause: CauseOptBase }>( | |
this: { new (): Instance }, | |
orig: Error | readonly Error[] | Instance['cause'] | readonly (Instance['cause'] | null | undefined | false)[], | |
cause?: Instance['cause'], | |
skip = 0, | |
) { | |
const obj = new this() as Instance & ErrorBase<Instance['cause']>; | |
return obj.ensure(orig, cause, skip + 1); | |
} | |
static causes<Instance extends { cause: CauseOptBase }>(this: { new (): Instance }, some: unknown, skip = 0) { | |
const obj = new this() as Instance & ErrorBase<Instance['cause']>; | |
return obj.causes(some, skip + 1); | |
} | |
// Что б дважды не нормализовывать один и тот же объект. | |
protected static normalized = new WeakMap<object, Error>(); | |
ensure( | |
orig: Error | readonly Error[] | CauseOpt | readonly (CauseOpt | null | undefined | false)[], | |
cause?: CauseOpt, | |
skip = 0, | |
) { | |
return this.normalize(orig, cause, skip + 1); | |
} | |
normalize( | |
orig: Error | readonly Error[] | CauseOpt | readonly (CauseOpt | null | undefined | false)[] | unknown, | |
cause?: CauseOpt, | |
// При нормализации могут создаваться новые инстансы Error, из стека вырезаются трейсы внутри либы, что б не замусоривать | |
skip = 0, | |
) { | |
// Один и тот же объект может быть брошен как ошибка и пропущен через ensure несколько раз. | |
// Стараемся получить первоначальный нормализованный инстанс ошибки, используя объект как ключ в WeakMap. | |
// Для примитивных типов, WeakMap не работает, обычный Map приведет к утечке памяти. | |
// Поэтому, если orig - примитивный тип, будет создаваться каждый раз новый инстанс Error. | |
const key = typeof orig === 'object' || (typeof orig === 'function' && orig) ? orig : undefined; | |
const normalized = key ? ErrorBase.normalized.get(key) : undefined; | |
let err: undefined | (Error & Partial<ErrorEx<CauseOpt>>) = | |
normalized ?? (orig instanceof Error ? orig : undefined); | |
if (!err) { | |
// Всем, далее создаваемым ошибкам назначаем стек, вырезая из него мусорные трейсы | |
const stack = this.stackFilter(new Error().stack, skip + 1); | |
const errors = (Array.isArray(orig) ? orig : [orig]) | |
.map((some: (Error & Partial<ErrorEx<CauseOpt>>) | CauseOpt | string | number | null | undefined) => | |
typeof some === 'object' && some !== null ? some : ({ message: String(some ?? 'UNK') } as CauseOpt), | |
) | |
.map((some) => { | |
if (some instanceof Error) return some; | |
try { | |
return Object.assign(new Error(some.message ?? some.code ?? some.__typename ?? 'UNK'), { | |
stack, | |
cause: some ?? undefined, | |
code: some.code ?? undefined, | |
statusCode: some.statusCode ?? undefined, | |
}); | |
} catch (e) { | |
return new Error('Error building error from DTO'); | |
} | |
}); | |
if (errors.length === 0) { | |
err = Object.assign(new Error('Empty array in ErrorBase.ensure'), { stack }); | |
} else if (errors.length === 1) { | |
err = errors[0]; | |
} else { | |
let messages = [] as string[]; | |
try { | |
messages = errors.map((err) => err.message ?? 'UNK'); | |
} catch (e) { | |
messages = [`Can't build message for AggregateError`]; | |
} | |
err = Object.assign(new AggregateErrorSafe(errors, `[${errors.length}] ${messages.join(', ')}`), { | |
stack, | |
}); | |
} | |
if (key) ErrorBase.normalized.set(key, err); | |
} | |
try { | |
// Падать при нормализации ошибок никак нельзя. | |
// В orig может прийти прокся, которая бросает ексепшен, при обращении к свойствам. | |
if (!err.cause || typeof err.cause !== 'object') err.cause = {} as CauseOpt; | |
if (cause) { | |
// Мержим cause в err.cause | |
// Не перезатираем существующие значения в err.cause, добавляем с суффиксом | |
for (const prop of Object.keys(cause)) { | |
const prev = err.cause[prop as keyof CauseOpt]; | |
const next = cause[prop as keyof CauseOpt]; | |
err.cause[prop as keyof CauseOpt] = next; | |
if (!prev || prev === next) continue; | |
for (let i = 1; i < 99; i++) { | |
const key = `${prop}-${i}` as keyof CauseOpt; | |
if (err.cause[key]) continue; | |
err.cause[key] = prev; | |
break; | |
} | |
} | |
} | |
if (cause?.message && !err.message.includes(cause.message)) { | |
// Когда передаем ошибку в ensure вместе с каузой и в каузе есть message, | |
// дописываем его к основному message ошибки, | |
// предварительно проверив, что оно уже не дописано ранее. | |
err.message = `${err.message ? `${err.message}: ` : ''}${cause.message}`; | |
} | |
if (!err.message) err.message = 'UNKNOWN'; | |
if (!err.stack?.match(/^\s+at\s+.*/)) { | |
// Некоторые кривые ошибки из fs приходят с пустым stack. | |
// Пытаемся восстановить стек, пускай от вызова ensure. | |
// Все внешние апи стоит оборачивать в try { unstable() } catch(e) { throw ErrorBase.ensure(e. { some context }) } | |
err.stack = this.stackFilter(new Error().stack, skip + 1); | |
} | |
// code - часто используется как поле в ошибке, дублируем его из каузы, если нет в ошибке | |
if (err.code === undefined) err.code = err.cause?.code ?? undefined; | |
// http statusCode более-менее стандартный в некоторых node-фреймворках, | |
// его тоже прикидываем из каузы, если не заполнен | |
if (err.statusCode === undefined) err.statusCode = err.cause?.statusCode ?? undefined; | |
// в label - имя функции или метода, из которого был вызван ensure первый раз для этой ошибки | |
if (!err.cause.label) err.cause.label = this.funcFromStack(err.stack); | |
} catch (e) { | |
console.warn(e); | |
} | |
return err as Error & ErrorEx<CauseOpt>; | |
} | |
causes(some: unknown, skip = 0) { | |
const host = this.normalize(some, undefined, skip + 1); | |
const errors = this.unpack(host); | |
return errors.map((error) => error.cause).filter(isPresent); | |
} | |
protected unpack<Err extends Error>(some: Err, result = [] as Err[]) { | |
if (some instanceof AggregateErrorSafe) { | |
for (const err of some.errors) { | |
if (err instanceof Error) this.unpack(err, result); | |
} | |
} else { | |
result.push(some); | |
} | |
return result; | |
} | |
protected funcFromLine(line?: string) { | |
return line?.match(/^\s+at\s+(?:(?:async\s+)?([^\s]*))/)?.[1]; | |
} | |
protected funcFromStack(stack?: string) { | |
const parts = stack?.split('\n') ?? []; | |
return this.funcFromLine(parts[1]); | |
} | |
protected stackFilter(stack?: string, skip = 1) { | |
const parts = stack?.split('\n') ?? []; | |
return [parts[0], ...parts.slice(skip + 1)].join('\n'); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment