diff --git a/src/backend/common/AbstractInitializable.ts b/src/backend/common/AbstractInitializable.ts index 0a10c034..a034130d 100644 --- a/src/backend/common/AbstractInitializable.ts +++ b/src/backend/common/AbstractInitializable.ts @@ -4,8 +4,8 @@ import { hasNodeNetworkException } from "./errors/NodeErrors.js"; import { hasUpstreamError } from "./errors/UpstreamError.js"; import { WebhookPayload } from "./infrastructure/config/health/webhooks.js"; import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError, TransformRulesError } from "./errors/MSErrors.js"; -import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; -import { spawn, catchAbortError, isAbortError, rethrowAbortError, delay, forever, AbortError, throwIfAborted } from 'abort-controller-x'; +import { messageWithCausesTruncatedDefault } from "../../core/ErrorUtils.js"; +import { spawn } from 'abort-controller-x'; export default abstract class AbstractInitializable { requiresAuth: boolean = false; diff --git a/src/backend/common/database/drizzle/entityUtils.ts b/src/backend/common/database/drizzle/entityUtils.ts index 5c208922..9d4241fb 100644 --- a/src/backend/common/database/drizzle/entityUtils.ts +++ b/src/backend/common/database/drizzle/entityUtils.ts @@ -9,7 +9,7 @@ import dayjs, { Dayjs } from "dayjs"; import { asPlay } from "../../../../core/PlayMarshalUtils.js"; import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../utils/PlayComparisonUtils.js"; import { hashObject } from "../../../utils/StringUtils.js"; -import { messageWithCauses } from "../../../utils/ErrorUtils.js"; +import { serializeError } from "serialize-error"; export const generateComponentEntity = (data: MarkOptional): ComponentNew => { assert(data.name !== undefined, 'Must provide name'); @@ -71,7 +71,7 @@ export const hydratePlaySelect = (select: PlaySelect, opts: PlayHydateOptions[] return res; } -export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates): DeadLetterScrobble => { +export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates, serializedError: boolean = false): DeadLetterScrobble => { const deadQueue = select.queueStates.find(x => x.queueName === CLIENT_DEAD_QUEUE); return { play: select.play, @@ -79,7 +79,7 @@ export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates): Dea source: select.play.meta.source, retries: deadQueue.retries, lastRetry: deadQueue.updatedAt, - error: deadQueue.error as unknown as string, + error: (serializedError ? serializeError(select.error) : select.error) as unknown as string, status: deadQueue.queueStatus as 'queued' | 'failed' } } diff --git a/src/backend/common/database/drizzle/schema/schema.ts b/src/backend/common/database/drizzle/schema/schema.ts index e5e2b467..a681b26c 100644 --- a/src/backend/common/database/drizzle/schema/schema.ts +++ b/src/backend/common/database/drizzle/schema/schema.ts @@ -6,7 +6,7 @@ import { ErrorLike, PlayObject } from "../../../../../core/Atomic.js"; import { asPlayCheap } from "../../../../../core/PlayMarshalUtils.js"; import { ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm } from "../../../infrastructure/Transform.js"; import { JobRangeCount, JobRangeTime } from "../../../infrastructure/Job.js"; -import { serializeError } from "serialize-error"; +import { serializeError, deserializeError } from "serialize-error"; const DayjsTimestamp = customType< { @@ -60,7 +60,7 @@ const ErrorLikeJson = customType< return JSON.stringify(serializeError(value)); }, fromDriver(value: string): ErrorLike { - return JSON.parse(value) + return deserializeError(JSON.parse(value)); }, }); diff --git a/src/backend/common/errors/MSErrors.ts b/src/backend/common/errors/MSErrors.ts index 06aba95f..915be1be 100644 --- a/src/backend/common/errors/MSErrors.ts +++ b/src/backend/common/errors/MSErrors.ts @@ -1,8 +1,9 @@ import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import mergeErrorCause from 'merge-error-cause'; -import { findCauseByFunc, findCauseByReference, isAbortReasonErrorLike } from "../../utils/ErrorUtils.js"; +import { findCauseByFunc, isAbortReasonErrorLike } from "../../utils/ErrorUtils.js"; import { UpstreamError, UpstreamErrorOptions } from "./UpstreamError.js"; import { isAbortError } from "abort-controller-x"; +import {addKnownErrorConstructor} from 'serialize-error'; export abstract class NamedError extends Error { public abstract name: string; @@ -13,32 +14,37 @@ export abstract class StageError extends NamedError {} export class BuildDataError extends StageError { name = 'Init Build Data'; } +addKnownErrorConstructor(BuildDataError); export class ParseCacheError extends StageError { name = 'Init Parse Cache'; } +addKnownErrorConstructor(ParseCacheError); export class TransformRulesError extends StageError { name = 'Transform Rules'; } - +addKnownErrorConstructor(TransformRulesError); export class ConnectionCheckError extends StageError { name = 'Connection Check'; } +addKnownErrorConstructor(ConnectionCheckError); export class AuthCheckError extends StageError { name = 'Authentication Check'; } +addKnownErrorConstructor(AuthCheckError); export class PostInitError extends StageError { name = 'Post Initialization'; } +addKnownErrorConstructor(PostInitError); const STACK_AT_REGEX = new RegExp(/[\n\r]\s*at/); export class SimpleError extends Error implements HasSimpleError { simple: boolean; - name = 'Error'; + name = 'SimpleError'; stackShortened: boolean = false; @@ -68,6 +74,7 @@ export class SimpleError extends Error implements HasSimpleError { } } } +addKnownErrorConstructor(SimpleError, () => new SimpleError('')) export class StageTransformError extends NamedError { name = 'Stage Transform'; @@ -77,14 +84,17 @@ export class StageTransformError extends NamedError { this.stageName = name; } } +addKnownErrorConstructor(StageTransformError, () => new StageTransformError('','')) export class SkipTransformStageError extends SimpleError { name = 'Skip Transform Stage'; } +addKnownErrorConstructor(SkipTransformStageError, () => new SkipTransformStageError('')) export class StagePrerequisiteError extends SimpleError { name = 'Stage Prerequisite'; } +addKnownErrorConstructor(StagePrerequisiteError, () => new StagePrerequisiteError('')) export interface HasSimpleError extends Error { simple: boolean @@ -116,10 +126,13 @@ export class ScrobbleSubmitError extends U this.payload = options?.payload; } } +addKnownErrorConstructor(ScrobbleSubmitError, () => new ScrobbleSubmitError('')) export class AbortedError extends SimpleError { - name = 'Aborted Operation'; + override name = 'Aborted Operation'; } +addKnownErrorConstructor(AbortedError, () => new AbortedError('')); + export const generateLoggableAbortReason = (msg: string, signal: AbortSignal): AbortedError => { const reason = signal.reason; let err: AbortedError; @@ -133,6 +146,7 @@ export const generateLoggableAbortReason = (msg: string, signal: AbortSignal): A } export class InvalidRegexError extends SimpleError { + override name = 'Invalid Regex Error'; constructor(regex: RegExp | RegExp[], val?: string, url?: string, message?: string) { const msgParts = [ message ?? 'Regex(es) did not match the value given.', @@ -149,4 +163,5 @@ export class InvalidRegexError extends SimpleError { } super(msgParts.join('\r\n')); } -} \ No newline at end of file +} +addKnownErrorConstructor(InvalidRegexError, () => new InvalidRegexError(new RegExp(/1/))); diff --git a/src/backend/common/errors/UpstreamError.ts b/src/backend/common/errors/UpstreamError.ts index c0f1b922..ec5a7816 100644 --- a/src/backend/common/errors/UpstreamError.ts +++ b/src/backend/common/errors/UpstreamError.ts @@ -1,11 +1,13 @@ import { Response } from 'superagent'; import { findCauseByFunc } from "../../utils/ErrorUtils.js"; +import { addKnownErrorConstructor } from 'serialize-error'; export type UpstreamErrorOptions = ErrorOptions & { showStopper?: boolean, response?: Response, responseBody?: object | string }; export class UpstreamError extends Error { + name = 'UpstreamError'; showStopper: boolean = false; response?: Response responseBody?: object | string @@ -34,4 +36,5 @@ export const findUpstreamError = (err: any, showStopping?: boolean): UpstreamErr } return false; }); -} \ No newline at end of file +} +addKnownErrorConstructor(UpstreamError, () => new UpstreamError('')) \ No newline at end of file diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 0ee6efb7..923f9011 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -50,7 +50,9 @@ import { sleep, sortByOldestPlayDate, } from "../utils.js"; -import { findCauseByReference, messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; +import { findCauseByReference } from "../utils/ErrorUtils.js"; +import { ErrorIsh, messageWithCausesTruncatedDefault } from "../../core/ErrorUtils.js"; +import { messageWithCauses } from "../../core/ErrorUtils.js"; import { comparePlayTemporally, getTemporalAccuracyCloseVal, @@ -1040,7 +1042,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i let successState: PlaySelect['state']; try { - + if (this.upstreamRefresh.refreshEnabled) { try { historicalPlays = await this.getSOTScrobblesForPlay(currQueuedPlay.play); @@ -1471,12 +1473,6 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i } addDeadLetterScrobble = async (data: PlaySelect, error: (Error | string) = 'Unspecified error') => { - let eString = ''; - if(typeof error === 'string') { - eString = error; - } else { - eString = messageWithCauses(error); - } let e: ErrorLike; if(isErrorLike(error)) { @@ -1492,8 +1488,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i playId: data.id, queueName: CLIENT_DEAD_QUEUE }); - // TODO ? - const deadData = {id: nanoid(), retries: 0, error: eString, play: data.play}; + const deadData = {id: nanoid(), retries: 0, error: e, play: data.play}; //this.deadLetterScrobbles.push(deadData); //this.deadLetterScrobbles.sort((a, b) => sortByOldestPlayDate(a.play, b.play)); this.emitEvent('deadLetter', {dead: deadData}); diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 34843468..1ff206b5 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -341,7 +341,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } // @ts-ignore - const result: DeadLetterScrobble[] = (await (client as AbstractScrobbleClient).getPlaysPaginated(deadQuery)).data.map(playSelectToDeadScrobble); + const result: DeadLetterScrobble[] = (await (client as AbstractScrobbleClient).getPlaysPaginated(deadQuery)).data.map(x => playSelectToDeadScrobble(x, true)); return res.json(result); }); @@ -377,7 +377,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro if(scrobbled) { return res.status(200).send(); } - return res.json(playSelectToDeadScrobble(dead)); + return res.json(playSelectToDeadScrobble(dead, true)); } catch (e) { if(e.message.includes(`Play ${deadId} does not exist`)) { logger.warn(e); diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 75b89f1d..4c28b9e4 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -37,8 +37,8 @@ import { timeToHumanTimestamp } from "../../core/TimeUtils.js"; import { todayAwareFormat } from "../../core/TimeUtils.js"; import { getRoot } from '../ioc.js'; import { componentFileLogger } from '../common/logging.js'; -import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js'; -import { isAbortReasonErrorLike, messageWithCauses, messageWithCausesTruncatedDefault } from '../utils/ErrorUtils.js'; +import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';; +import { messageWithCausesTruncatedDefault } from "../../core/ErrorUtils.js"; import { existingScrobble, ExistingScrobbleOpts, genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js'; import { findAsync, staggerMapper, StaggerOptions } from '../utils/AsyncUtils.js'; import pMap, {pMapIterable} from 'p-map'; diff --git a/src/backend/tests/errors/errors.test.ts b/src/backend/tests/errors/errors.test.ts new file mode 100644 index 00000000..f58ee58c --- /dev/null +++ b/src/backend/tests/errors/errors.test.ts @@ -0,0 +1,48 @@ +import chai, { assert, expect } from 'chai'; +import asPromised from 'chai-as-promised'; +import { after } from 'mocha'; +import { UpstreamError } from '../../common/errors/UpstreamError.js'; +import { serializeError, deserializeError } from 'serialize-error'; +import { AbortedError } from '../../common/errors/MSErrors.js'; + + +describe('#ErrorMarshalling', function() { + + it('serializes custom error with name',function() { + + const up = new UpstreamError('a test upstream'); + + const s = serializeError(up); + + expect(s.name).eq('UpstreamError'); + }); + + it('deserializes custom error',function() { + + const up = new UpstreamError('a test upstream', {showStopper: true}); + + const s = serializeError(up); + + const e = deserializeError(s); + + expect(e).instanceOf(UpstreamError); + expect((e as UpstreamError).showStopper).eq(true); + + const a = new AbortedError('aborted test'); + const aMarshalled = deserializeError(serializeError(a)); + expect(aMarshalled).instanceOf(AbortedError); + }); + + it('deserializes custom nested error',function() { + + const sourceError = new Error('a test', {cause: new UpstreamError('a test upstream', {showStopper: true})}); + + const s = serializeError(sourceError); + + const e = deserializeError(s); + + expect(e.cause).instanceOf(UpstreamError); + expect((e.cause as UpstreamError).showStopper).eq(true); + }); + +}); \ No newline at end of file diff --git a/src/backend/utils/ErrorUtils.ts b/src/backend/utils/ErrorUtils.ts index f4fbfa14..3642b45b 100644 --- a/src/backend/utils/ErrorUtils.ts +++ b/src/backend/utils/ErrorUtils.ts @@ -1,5 +1,5 @@ import { isAbortError } from "abort-controller-x"; -import { truncateStringToLength } from "../../core/StringUtils.js"; +import { getErrorCause } from "../../core/ErrorUtils.js"; /** * Adapted from https://github.com/voxpelli/pony-cause/blob/main/lib/helpers.js to find cause by truthy function @@ -25,37 +25,12 @@ export const findCauseByFunc = (err: any, func: (e: Err return currentErr as T; } - currentErr = getErrorCause(currentErr); + currentErr = getErrorCause(currentErr) as unknown as T; } }; export const findCauseByMessage = (err: any, msg: string) => { return findCauseByFunc(err, (e => e.message.toLocaleLowerCase().includes(msg.toLocaleLowerCase()))); } -/** - * Adapted from https://github.com/voxpelli/pony-cause - * */ -export const getErrorCause = (err: Error | { - cause?: unknown | (() => Error | { - cause?: unknown | (() => Error | any); - }); -}): Error | undefined => { - if (!err) return; - - const cause = err.cause; - - // VError / NError style causes - if (typeof cause === 'function') { - const causeResult = cause(); - - return causeResult instanceof Error - ? causeResult - : undefined; - } else { - return cause instanceof Error - ? cause - : undefined; - } -}; /** * Adapted from https://github.com/voxpelli/pony-cause * */ @@ -77,46 +52,8 @@ export const findCauseByReference = (err: unknown, reference: n return currentErr; } - currentErr = getErrorCause(currentErr); - } -}; - -export type MessageTransformer = (val: string) => string; -export const MessageTransformerDefault = (val: string) => val; -/** - * Adapted from https://github.com/voxpelli/pony-cause - * */ -const _messageWithCauses = (err: Error, seen = new Set(), msgTransform: MessageTransformer = MessageTransformerDefault): string => { - if (!(err instanceof Error)) return ''; - - const message = err.message; - - // Ensure we don't go circular or crazily deep - if (seen.has(err)) { - return msgTransform(message) + ' => ...'; - } - - const cause = getErrorCause(err); - - if (cause) { - seen.add(err); - - return (msgTransform(message) + ' => ' + - _messageWithCauses(cause, seen)); - } else { - return msgTransform(message); + currentErr = getErrorCause(currentErr) as unknown as T; } }; -/** - * Adapted from https://github.com/voxpelli/pony-cause - * */ -export const messageWithCauses = (err: Error, msgTransformer?: MessageTransformer) => _messageWithCauses(err, new Set(), msgTransformer); - -export const messageWithCausesTruncated = (length: number) => { - const t = truncateStringToLength(length); - return (err: Error) => messageWithCauses(err, t); -} - -export const messageWithCausesTruncatedDefault = messageWithCausesTruncated(100); export const isAbortReasonErrorLike = (signal: AbortSignal) => signal.aborted && signal.reason !== undefined && (isAbortError(signal.reason) || signal.reason instanceof Error); \ No newline at end of file diff --git a/src/client/deadLetter/DeadPage.tsx b/src/client/deadLetter/DeadPage.tsx index 27645246..271386ea 100644 --- a/src/client/deadLetter/DeadPage.tsx +++ b/src/client/deadLetter/DeadPage.tsx @@ -18,6 +18,7 @@ import { faBug } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { useCopyToClipboard } from '../components/copyToClipboardHook'; +import { messageWithCauses } from '../../core/ErrorUtils'; const displayOpts = { include: recentIncludes, @@ -68,21 +69,24 @@ const dead = (props: PropsFromRedux) => { {isSuccess && !isLoading && data.length === 0 ? 'No failed scrobbles!' : null}
    {data.map(x => { - let errText = x.error; - if(typeof x.error === 'object' && x.error !== null) { - errText = JSON.stringify(x.error); + let errorSummary: string; + try { + // @ts-expect-error + errorSummary = messageWithCauses(x.error, undefined, '\n caused by: '); + } catch (e) { + console.warn(new Error('Could not convert error to summary', {cause: e})); } const classes = [...baseClass].concat(copiedIndex !== x.id ? ['underline','cursor-pointer'] : []); return (
  • -
    Source:{x.source === undefined ? 'Source - Unknown' : x.source.replace('Source -', '')}
    Status:{x.status}
    Retries: {x.retries}
    Last Retried: {x.lastRetry === undefined ? 'Never' : dayjs.duration(dayjs(x.lastRetry).diff(dayjs())).humanize(true)}
    -
    Error: {errText}
    + {errorSummary === undefined ? null :
    Error Summary:
    {errorSummary}
    }
    retryDead(x.id)} className="capitalize underline cursor-pointer max-w-fit">Retry
    removeDead(x.id)} className="capitalize underline cursor-pointer max-w-fit">Remove
  • ) diff --git a/src/client/recent/RecentPage.tsx b/src/client/recent/RecentPage.tsx index f5171271..27cf0e72 100644 --- a/src/client/recent/RecentPage.tsx +++ b/src/client/recent/RecentPage.tsx @@ -94,7 +94,7 @@ const recent = () => { // return y; // }); return
  • -
  • diff --git a/src/client/scrobbled/ScrobbledPage.tsx b/src/client/scrobbled/ScrobbledPage.tsx index b65c45b7..0c10866a 100644 --- a/src/client/scrobbled/ScrobbledPage.tsx +++ b/src/client/scrobbled/ScrobbledPage.tsx @@ -47,7 +47,7 @@ const scrobbled = () => { const classes = [...baseClass].concat(copiedIndex !== x.index ? ['underline','cursor-pointer'] : []); return
  • -
  • ; diff --git a/src/core/ErrorUtils.ts b/src/core/ErrorUtils.ts new file mode 100644 index 00000000..320c5290 --- /dev/null +++ b/src/core/ErrorUtils.ts @@ -0,0 +1,67 @@ +import { type MarkOptional } from "ts-essentials"; +import { truncateStringToLength } from "./StringUtils.js"; +import { ErrorLike, isErrorLike } from 'serialize-error'; + +export type ErrorIsh = Error | MarkOptional; + +/** + * Adapted from https://github.com/voxpelli/pony-cause + * */ +export const _messageWithCauses = (err: ErrorIsh, seen = new Set(), msgTransform: MessageTransformer = MessageTransformerDefault, joiner: string = ' => '): string => { + if (!(err instanceof Error) && !isErrorLike(err)) return ''; + + const message = err.message; + + // Ensure we don't go circular or crazily deep + if (seen.has(err)) { + return msgTransform(message) + `${joiner}...`; + } + + const cause = getErrorCause(err); + + if (cause) { + seen.add(err); + + return (msgTransform(message) + joiner + + _messageWithCauses(cause, seen, msgTransform, joiner)); + } else { + return msgTransform(message); + } +};/** + * Adapted from https://github.com/voxpelli/pony-cause + * */ +export const messageWithCauses = (err: ErrorIsh, msgTransformer?: MessageTransformer, joiner?: string) => _messageWithCauses(err, new Set(), msgTransformer, joiner); +export const messageWithCausesTruncated = (length: number) => { + const t = truncateStringToLength(length); + return (err: ErrorIsh) => messageWithCauses(err, t); +}; +export type MessageTransformer = (val: string) => string; +export const MessageTransformerDefault = (val: string) => val; +export const messageWithCausesTruncatedDefault = messageWithCausesTruncated(100); +/** + * Adapted from https://github.com/voxpelli/pony-cause + * */ +export const getErrorCause = (err: Error | + ErrorLike | { + cause?: unknown | (() => ErrorIsh | { + cause?: unknown | (() => ErrorIsh | any); + }); +}): ErrorIsh | undefined => { + if (!err) return; + + const cause = err.cause; + + // VError / NError style causes + if (typeof cause === 'function') { + const causeResult = cause(); + + return isErrorLike(causeResult) + ? causeResult + : undefined; + } else { + return isErrorLike(cause) + ? cause + : undefined; + } +}; +