diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 5e2d517c8c..606ab4c26e 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -25,6 +25,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { isRetryableError } from '@/misc/is-retryable-error.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; @@ -707,7 +708,7 @@ export class ApNoteService { this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${e}`); } - return (e instanceof StatusError && e.isRetryable); + return isRetryableError(e); } }; diff --git a/packages/backend/src/misc/is-retryable-error.ts b/packages/backend/src/misc/is-retryable-error.ts new file mode 100644 index 0000000000..9bb8700c7a --- /dev/null +++ b/packages/backend/src/misc/is-retryable-error.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AbortError } from 'node-fetch'; +import { UnrecoverableError } from 'bullmq'; +import { StatusError } from '@/misc/status-error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +/** + * Returns false if the provided value represents a "permanent" error that cannot be retried. + * Returns true if the error is retryable, unknown (as all errors are retryable by default), or not an error object. + */ +export function isRetryableError(e: unknown): boolean { + if (e instanceof StatusError) return e.isRetryable; + if (e instanceof IdentifiableError) return e.isRetryable; + if (e instanceof UnrecoverableError) return false; + if (e instanceof AbortError) return true; + if (e instanceof Error) return e.name === 'AbortError'; + return true; +} diff --git a/packages/backend/test/unit/misc/is-retryable-error.ts b/packages/backend/test/unit/misc/is-retryable-error.ts new file mode 100644 index 0000000000..096bf64d4f --- /dev/null +++ b/packages/backend/test/unit/misc/is-retryable-error.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { UnrecoverableError } from 'bullmq'; +import { AbortError } from 'node-fetch'; +import { isRetryableError } from '@/misc/is-retryable-error.js'; +import { StatusError } from '@/misc/status-error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +describe(isRetryableError, () => { + it('should return true for retryable StatusError', () => { + const error = new StatusError('test error', 500); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return false for permanent StatusError', () => { + const error = new StatusError('test error', 400); + const result = isRetryableError(error); + expect(result).toBeFalsy(); + }); + + it('should return true for retryable IdentifiableError', () => { + const error = new IdentifiableError('id', 'message', true); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return false for permanent StatusError', () => { + const error = new IdentifiableError('id', 'message', false); + const result = isRetryableError(error); + expect(result).toBeFalsy(); + }); + + it('should return false for UnrecoverableError', () => { + const error = new UnrecoverableError(); + const result = isRetryableError(error); + expect(result).toBeFalsy(); + }); + + it('should return true for typed AbortError', () => { + const error = new AbortError(); + const result = isRetryableError(error); + expect(result).toBeTruthy(); + }); + + it('should return true for named AbortError', () => { + const error = new Error(); + error.name = 'AbortError'; + + const result = isRetryableError(error); + + expect(result).toBeTruthy(); + }); + + const nonErrorInputs = [ + [null, 'null'], + [undefined, 'undefined'], + [0, 'number'], + ['string', 'string'], + [true, 'boolean'], + [[], 'array'], + [{}, 'object'], + ]; + for (const [input, label] of nonErrorInputs) { + it(`should return true for ${label} input`, () => { + const result = isRetryableError(input); + expect(result).toBeTruthy(); + }); + } +});