more use of identifiable errors, improvements to inner error rendering, and more heuristics for is-retryable-error

This commit is contained in:
Hazelnoot 2025-05-22 12:27:54 -04:00
parent c8797451e3
commit 2cba0ada3c
33 changed files with 241 additions and 157 deletions

View file

@ -13,6 +13,7 @@ import { QueueService } from '@/core/QueueService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { IdService } from './IdService.js'; import { IdService } from './IdService.js';
@Injectable() @Injectable()
@ -125,11 +126,11 @@ export class AbuseReportService {
const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId }); const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
if (report.targetUserHost == null) { if (report.targetUserHost == null) {
throw new Error('The target user host is null.'); throw new IdentifiableError('0b1ce202-b2c1-4ee4-8af4-2742a51b383d', 'The target user host is null.');
} }
if (report.forwarded) { if (report.forwarded) {
throw new Error('The report has already been forwarded.'); throw new IdentifiableError('5c008bdf-f0e8-4154-9f34-804e114516d7', 'The report has already been forwarded.');
} }
await this.abuseUserReportsRepository.update(report.id, { await this.abuseUserReportsRepository.update(report.id, {

View file

@ -54,7 +54,7 @@ export class CaptchaError extends Error {
public readonly cause?: unknown; public readonly cause?: unknown;
constructor(code: CaptchaErrorCode, message: string, cause?: unknown) { constructor(code: CaptchaErrorCode, message: string, cause?: unknown) {
super(message); super(message, cause ? { cause } : undefined);
this.code = code; this.code = code;
this.cause = cause; this.cause = cause;
this.name = 'CaptchaError'; this.name = 'CaptchaError';
@ -117,7 +117,7 @@ export class CaptchaService {
} }
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => { const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`); throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`, err);
}); });
if (result.success !== true) { if (result.success !== true) {
@ -133,7 +133,7 @@ export class CaptchaService {
} }
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => { const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`); throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`, err);
}); });
if (result.success !== true) { if (result.success !== true) {
@ -209,7 +209,7 @@ export class CaptchaService {
} }
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`); throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`, err);
}); });
if (result.success !== true) { if (result.success !== true) {
@ -386,7 +386,7 @@ export class CaptchaService {
this.logger.info(err); this.logger.info(err);
const error = err instanceof CaptchaError const error = err instanceof CaptchaError
? err ? err
: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`); : new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`, err);
return { return {
success: false, success: false,
error, error,

View file

@ -296,7 +296,7 @@ export class NoteCreateService implements OnApplicationShutdown {
case 'followers': case 'followers':
// 他人のfollowers noteはreject // 他人のfollowers noteはreject
if (data.renote.userId !== user.id) { if (data.renote.userId !== user.id) {
throw new Error('Renote target is not public or home'); throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
} }
// Renote対象がfollowersならfollowersにする // Renote対象がfollowersならfollowersにする
@ -304,7 +304,7 @@ export class NoteCreateService implements OnApplicationShutdown {
break; break;
case 'specified': case 'specified':
// specified / direct noteはreject // specified / direct noteはreject
throw new Error('Renote target is not public or home'); throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
} }
} }
@ -317,7 +317,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.renote.userId !== user.id) { if (data.renote.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
if (blocked) { if (blocked) {
throw new Error('blocked'); throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is blocked');
} }
} }
} }
@ -489,10 +489,10 @@ export class NoteCreateService implements OnApplicationShutdown {
// should really not happen, but better safe than sorry // should really not happen, but better safe than sorry
if (data.reply?.id === insert.id) { if (data.reply?.id === insert.id) {
throw new Error('A note can\'t reply to itself'); throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', 'A note can\'t reply to itself');
} }
if (data.renote?.id === insert.id) { if (data.renote?.id === insert.id) {
throw new Error('A note can\'t renote itself'); throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', 'A note can\'t renote itself');
} }
if (data.uri != null) insert.uri = data.uri; if (data.uri != null) insert.uri = data.uri;

View file

@ -309,7 +309,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (this.isRenote(data)) { if (this.isRenote(data)) {
if (data.renote.id === oldnote.id) { if (data.renote.id === oldnote.id) {
throw new UnrecoverableError(`edit failed for ${oldnote.id}: cannot renote itself`); throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldnote.id}: cannot renote itself`);
} }
switch (data.renote.visibility) { switch (data.renote.visibility) {
@ -325,7 +325,7 @@ export class NoteEditService implements OnApplicationShutdown {
case 'followers': case 'followers':
// 他人のfollowers noteはreject // 他人のfollowers noteはreject
if (data.renote.userId !== user.id) { if (data.renote.userId !== user.id) {
throw new Error('Renote target is not public or home'); throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
} }
// Renote対象がfollowersならfollowersにする // Renote対象がfollowersならfollowersにする
@ -333,7 +333,7 @@ export class NoteEditService implements OnApplicationShutdown {
break; break;
case 'specified': case 'specified':
// specified / direct noteはreject // specified / direct noteはreject
throw new Error('Renote target is not public or home'); throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
} }
} }

View file

@ -17,6 +17,7 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
@Injectable() @Injectable()
export class RemoteUserResolveService { export class RemoteUserResolveService {
@ -119,8 +120,8 @@ export class RemoteUserResolveService {
@bindThis @bindThis
private async resolveSelf(acctLower: string): Promise<ILink> { private async resolveSelf(acctLower: string): Promise<ILink> {
const finger = await this.webfingerService.webfinger(acctLower).catch(err => { const finger = await this.webfingerService.webfinger(acctLower).catch(err => {
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`); this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${renderInlineError(err)}`);
throw new Error(`Failed to WebFinger for ${acctLower}: ${ err.statusCode ?? err.message }`); throw new Error(`Failed to WebFinger for ${acctLower}: error thrown`, { cause: err });
}); });
const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self'); const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self');
if (!self) { if (!self) {

View file

@ -121,7 +121,7 @@ export class WebAuthnService {
}); });
} catch (error) { } catch (error) {
this.logger.error(error as Error, 'Error authenticating webauthn'); this.logger.error(error as Error, 'Error authenticating webauthn');
throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed'); throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed', true, error);
} }
const { verified } = verification; const { verified } = verification;
@ -227,7 +227,7 @@ export class WebAuthnService {
requireUserVerification: true, requireUserVerification: true,
}); });
} catch (error) { } catch (error) {
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`); throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed`, true, error);
} }
const { verified, authenticationInfo } = verification; const { verified, authenticationInfo } = verification;
@ -308,7 +308,7 @@ export class WebAuthnService {
}); });
} catch (error) { } catch (error) {
this.logger.error(error as Error, 'Error authenticating webauthn'); this.logger.error(error as Error, 'Error authenticating webauthn');
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed'); throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed', true, error);
} }
const { verified, authenticationInfo } = verification; const { verified, authenticationInfo } = verification;

View file

@ -57,7 +57,7 @@ class DeliverManager {
) { ) {
// 型で弾いてはいるが一応ローカルユーザーかチェック // 型で弾いてはいるが一応ローカルユーザーかチェック
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (actor.host != null) throw new Error('actor.host must be null'); if (actor.host != null) throw new Error(`deliver failed for ${actor.id}: host is not null`);
// パフォーマンス向上のためキューに突っ込むのはidのみに絞る // パフォーマンス向上のためキューに突っ込むのはidのみに絞る
this.actor = { this.actor = {
@ -124,12 +124,13 @@ class DeliverManager {
select: { select: {
followerSharedInbox: true, followerSharedInbox: true,
followerInbox: true, followerInbox: true,
followerId: true,
}, },
}); });
for (const following of followers) { for (const following of followers) {
const inbox = following.followerSharedInbox ?? following.followerInbox; const inbox = following.followerSharedInbox ?? following.followerInbox;
if (inbox === null) throw new UnrecoverableError(`inbox is null: following ${following.id}`); if (inbox === null) throw new UnrecoverableError(`deliver failed for ${this.actor.id}: follower ${following.followerId} inbox is null`);
inboxes.set(inbox, following.followerSharedInbox != null); inboxes.set(inbox, following.followerSharedInbox != null);
} }
} }

View file

@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common';
import { UnrecoverableError } from 'bullmq'; import { UnrecoverableError } from 'bullmq';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { StatusError } from '@/misc/status-error.js';
import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js'; import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js';
import { validateContentTypeSetAsJsonLD } from './misc/validator.js'; import { validateContentTypeSetAsJsonLD } from './misc/validator.js';
import type { JsonLdDocument } from 'jsonld'; import type { JsonLdDocument } from 'jsonld';
@ -149,7 +150,7 @@ class JsonLd {
}, },
).then(res => { ).then(res => {
if (!res.ok) { if (!res.ok) {
throw new Error(`JSON-LD fetch failed with ${res.status} ${res.statusText}: ${url}`); throw new StatusError(`failed to fetch JSON-LD from ${url}`, res.status, res.statusText);
} else { } else {
return res.json(); return res.json();
} }

View file

@ -6,8 +6,6 @@
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { Response } from 'node-fetch'; import type { Response } from 'node-fetch';
// TODO throw identifiable or unrecoverable errors
export function validateContentTypeSetAsActivityPub(response: Response): void { export function validateContentTypeSetAsActivityPub(response: Response): void {
const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); const contentType = (response.headers.get('content-type') ?? '').toLowerCase();

View file

@ -18,7 +18,7 @@ import type { Config } from '@/config.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApResolverService } from '../ApResolverService.js'; import { ApResolverService } from '../ApResolverService.js';
import { ApLoggerService } from '../ApLoggerService.js'; import { ApLoggerService } from '../ApLoggerService.js';
import { isDocument, type IObject } from '../type.js'; import { getNullableApId, isDocument, type IObject } from '../type.js';
@Injectable() @Injectable()
export class ApImageService { export class ApImageService {
@ -48,7 +48,7 @@ export class ApImageService {
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> { public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
// 投稿者が凍結されていたらスキップ // 投稿者が凍結されていたらスキップ
if (actor.isSuspended) { if (actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${actor.uri}`); throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create image ${getNullableApId(value)}: actor ${actor.id} has been suspended`);
} }
const image = await this.apResolverService.createResolver().resolve(value); const image = await this.apResolverService.createResolver().resolve(value);

View file

@ -175,11 +175,11 @@ export class ApNoteService {
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id == null) { if (note.id == null) {
throw new UnrecoverableError(`Refusing to create note without id: ${entryUri}`); throw new UnrecoverableError(`failed to create note ${entryUri}: missing ID`);
} }
if (!checkHttps(note.id)) { if (!checkHttps(note.id)) {
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`); throw new UnrecoverableError(`failed to create note ${entryUri}: unexpected schema`);
} }
const url = this.apUtilityService.findBestObjectUrl(note); const url = this.apUtilityService.findBestObjectUrl(note);
@ -188,7 +188,7 @@ export class ApNoteService {
// 投稿者をフェッチ // 投稿者をフェッチ
if (note.attributedTo == null) { if (note.attributedTo == null) {
throw new UnrecoverableError(`invalid note.attributedTo ${note.attributedTo} in ${entryUri}`); throw new UnrecoverableError(`failed to create note: ${entryUri}: missing attributedTo`);
} }
const uri = getOneApId(note.attributedTo); const uri = getOneApId(note.attributedTo);
@ -271,14 +271,14 @@ export class ApNoteService {
.then(x => { .then(x => {
if (x == null) { if (x == null) {
this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`); this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`);
throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`); throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true);
} }
return x; return x;
}) })
.catch(async err => { .catch(async err => {
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
throw err; throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
}) })
: null; : null;
@ -349,7 +349,7 @@ export class ApNoteService {
this.logger.info('The note is already inserted while creating itself, reading again'); this.logger.info('The note is already inserted while creating itself, reading again');
const duplicate = await this.fetchNote(value); const duplicate = await this.fetchNote(value);
if (!duplicate) { if (!duplicate) {
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${entryUri}`); throw new IdentifiableError('39c328e1-e829-458b-bfc9-65dcd513d1f8', `failed to create note ${entryUri}: the note creation failed with duplication error even when there is no duplication. This is likely a bug.`);
} }
return duplicate; return duplicate;
} }
@ -363,45 +363,39 @@ export class ApNoteService {
const noteUri = getApId(value); const noteUri = getApId(value);
// URIがこのサーバーを指しているならスキップ // URIがこのサーバーを指しているならスキップ
if (noteUri.startsWith(this.config.url + '/')) throw new UnrecoverableError(`uri points local: ${noteUri}`); if (this.utilityService.isUriLocal(noteUri)) {
throw new UnrecoverableError(`failed to update note ${noteUri}: uri is local`);
}
//#region このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri }); const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
if (updatedNote == null) throw new Error(`Note is not registered (no note): ${noteUri}`); if (updatedNote == null) throw new UnrecoverableError(`failed to update note ${noteUri}: note does not exist`);
const user = await this.usersRepository.findOneBy({ id: updatedNote.userId }) as MiRemoteUser | null; const user = await this.usersRepository.findOneBy({ id: updatedNote.userId }) as MiRemoteUser | null;
if (user == null) throw new Error(`Note is not registered (no user): ${noteUri}`); if (user == null) throw new UnrecoverableError(`failed to update note ${noteUri}: user does not exist`);
// eslint-disable-next-line no-param-reassign resolver ??= this.apResolverService.createResolver();
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value); const object = await resolver.resolve(value);
const entryUri = getApId(value); const entryUri = getApId(value);
const err = this.validateNote(object, entryUri, actor, user); const err = this.validateNote(object, entryUri, actor, user);
if (err) { if (err) {
this.logger.error(`Error updating note: ${renderInlineError(err)}`, { this.logger.error(`Failed to update note ${noteUri}: ${renderInlineError(err)}`);
resolver: { history: resolver.getHistory() },
value,
object,
});
throw err; throw err;
} }
// `validateNote` checks that the actor and user are one and the same // `validateNote` checks that the actor and user are one and the same
// eslint-disable-next-line no-param-reassign
actor ??= user; actor ??= user;
const note = object as IPost; const note = object as IPost;
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id == null) { if (note.id == null) {
throw new UnrecoverableError(`Refusing to update note without id: ${noteUri}`); throw new UnrecoverableError(`failed to update note ${entryUri}: missing ID`);
} }
if (!checkHttps(note.id)) { if (!checkHttps(note.id)) {
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`); throw new UnrecoverableError(`failed to update note ${entryUri}: unexpected schema`);
} }
const url = this.apUtilityService.findBestObjectUrl(note); const url = this.apUtilityService.findBestObjectUrl(note);
@ -475,14 +469,14 @@ export class ApNoteService {
.then(x => { .then(x => {
if (x == null) { if (x == null) {
this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`); this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`);
throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`); throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true);
} }
return x; return x;
}) })
.catch(async err => { .catch(async err => {
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
throw err; throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
}) })
: null; : null;
@ -550,7 +544,7 @@ export class ApNoteService {
this.logger.info('The note is already inserted while creating itself, reading again'); this.logger.info('The note is already inserted while creating itself, reading again');
const duplicate = await this.fetchNote(value); const duplicate = await this.fetchNote(value);
if (!duplicate) { if (!duplicate) {
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${noteUri}`); throw new IdentifiableError('39c328e1-e829-458b-bfc9-65dcd513d1f8', `failed to update note ${entryUri}: the note update failed with duplication error even when there is no duplication. This is likely a bug.`);
} }
return duplicate; return duplicate;
} }
@ -567,8 +561,7 @@ export class ApNoteService {
const uri = getApId(value); const uri = getApId(value);
if (!this.utilityService.isFederationAllowedUri(uri)) { if (!this.utilityService.isFederationAllowedUri(uri)) {
// TODO convert to identifiable error throw new IdentifiableError('04620a7e-044e-45ce-b72c-10e1bdc22e69', `failed to resolve note ${uri}: host is blocked`);
throw new StatusError(`blocked host: ${uri}`, 451, 'blocked host');
} }
//#region このサーバーに既に登録されていたらそれを返す //#region このサーバーに既に登録されていたらそれを返す
@ -578,8 +571,7 @@ export class ApNoteService {
// Bail if local URI doesn't exist // Bail if local URI doesn't exist
if (this.utilityService.isUriLocal(uri)) { if (this.utilityService.isUriLocal(uri)) {
// TODO convert to identifiable error throw new IdentifiableError('cbac7358-23f2-4c70-833e-cffb4bf77913', `failed to resolve note ${uri}: URL is local and does not exist`);
throw new StatusError(`cannot resolve local note: ${uri}`, 400, 'cannot resolve local note');
} }
const unlock = await this.appLockService.getApLock(uri); const unlock = await this.appLockService.getApLock(uri);

View file

@ -55,6 +55,7 @@ import type { ApLoggerService } from '../ApLoggerService.js';
import type { ApImageService } from './ApImageService.js'; import type { ApImageService } from './ApImageService.js';
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js'; import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
const nameLength = 128; const nameLength = 128;
const summaryLength = 2048; const summaryLength = 2048;
@ -158,21 +159,21 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const expectHost = this.utilityService.punyHostPSLDomain(uri); const expectHost = this.utilityService.punyHostPSLDomain(uri);
if (!isActor(x)) { if (!isActor(x)) {
throw new UnrecoverableError(`invalid Actor type '${x.type}' in ${uri}`); throw new UnrecoverableError(`invalid Actor ${uri}: unknown type '${x.type}'`);
} }
if (!(typeof x.id === 'string' && x.id.length > 0)) { if (!(typeof x.id === 'string' && x.id.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id type`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type`);
} }
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox type`);
} }
this.apUtilityService.assertApUrl(x.inbox); this.apUtilityService.assertApUrl(x.inbox);
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox); const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
if (inboxHost !== expectHost) { if (inboxHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox ${inboxHost}`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox host ${inboxHost}`);
} }
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined); const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
@ -180,7 +181,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const sharedInbox = getApId(sharedInboxObject); const sharedInbox = getApId(sharedInboxObject);
this.apUtilityService.assertApUrl(sharedInbox); this.apUtilityService.assertApUrl(sharedInbox);
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) { if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong shared inbox ${sharedInbox}`);
} }
} }
@ -191,7 +192,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
if (typeof collectionUri === 'string' && collectionUri.length > 0) { if (typeof collectionUri === 'string' && collectionUri.length > 0) {
this.apUtilityService.assertApUrl(collectionUri); this.apUtilityService.assertApUrl(collectionUri);
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) { if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} host ${collectionUri}`);
} }
} else if (collectionUri != null) { } else if (collectionUri != null) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`);
@ -200,7 +201,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
} }
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong username`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong username`);
} }
// These fields are only informational, and some AP software allows these // These fields are only informational, and some AP software allows these
@ -208,7 +209,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
// we can at least see these users and their activities. // we can at least see these users and their activities.
if (x.name) { if (x.name) {
if (!(typeof x.name === 'string' && x.name.length > 0)) { if (!(typeof x.name === 'string' && x.name.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong name`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong name`);
} }
x.name = truncate(x.name, nameLength); x.name = truncate(x.name, nameLength);
} else if (x.name === '') { } else if (x.name === '') {
@ -217,24 +218,24 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
} }
if (x.summary) { if (x.summary) {
if (!(typeof x.summary === 'string' && x.summary.length > 0)) { if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong summary`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong summary`);
} }
x.summary = truncate(x.summary, summaryLength); x.summary = truncate(x.summary, summaryLength);
} }
const idHost = this.utilityService.punyHostPSLDomain(x.id); const idHost = this.utilityService.punyHostPSLDomain(x.id);
if (idHost !== expectHost) { if (idHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id ${x.id}`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong id ${x.id}`);
} }
if (x.publicKey) { if (x.publicKey) {
if (typeof x.publicKey.id !== 'string') { if (typeof x.publicKey.id !== 'string') {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id type`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id type`);
} }
const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id); const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id);
if (publicKeyIdHost !== expectHost) { if (publicKeyIdHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id ${x.publicKey.id}`); throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id ${x.publicKey.id}`);
} }
} }
@ -272,8 +273,6 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
} }
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> { private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> {
if (user == null) throw new Error('failed to create user: user is null');
const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => { const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => {
// icon and image may be arrays // icon and image may be arrays
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon // see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
@ -326,12 +325,11 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
*/ */
@bindThis @bindThis
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> { public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
if (typeof uri !== 'string') throw new UnrecoverableError(`uri is not string: ${uri}`); if (typeof uri !== 'string') throw new UnrecoverableError(`failed to create user ${uri}: input is not string`);
const host = this.utilityService.punyHost(uri); const host = this.utilityService.punyHost(uri);
if (host === this.utilityService.toPuny(this.config.host)) { if (host === this.utilityService.toPuny(this.config.host)) {
// TODO convert to unrecoverable error throw new UnrecoverableError(`failed to create user ${uri}: URI is local`);
throw new StatusError(`cannot resolve local user: ${uri}`, 400, 'cannot resolve local user');
} }
return await this._createPerson(uri, resolver); return await this._createPerson(uri, resolver);
@ -341,8 +339,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const uri = getApId(value); const uri = getApId(value);
const host = this.utilityService.punyHost(uri); const host = this.utilityService.punyHost(uri);
// eslint-disable-next-line no-param-reassign resolver ??= this.apResolverService.createResolver();
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value); const object = await resolver.resolve(value);
const person = this.validateActor(object, uri); const person = this.validateActor(object, uri);
@ -375,7 +372,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
if (person.id == null) { if (person.id == null) {
throw new UnrecoverableError(`Refusing to create person without id: ${uri}`); throw new UnrecoverableError(`failed to create user ${uri}: missing ID`);
} }
const url = this.apUtilityService.findBestObjectUrl(person); const url = this.apUtilityService.findBestObjectUrl(person);
@ -568,7 +565,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
*/ */
@bindThis @bindThis
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> { public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
if (typeof uri !== 'string') throw new UnrecoverableError('uri is not string'); if (typeof uri !== 'string') throw new UnrecoverableError(`failed to update user ${uri}: input is not string`);
// URIがこのサーバーを指しているならスキップ // URIがこのサーバーを指しているならスキップ
if (this.utilityService.isUriLocal(uri)) return; if (this.utilityService.isUriLocal(uri)) return;
@ -624,7 +621,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
if (person.id == null) { if (person.id == null) {
throw new UnrecoverableError(`Refusing to update person without id: ${uri}`); throw new UnrecoverableError(`failed to update user ${uri}: missing ID`);
} }
const url = this.apUtilityService.findBestObjectUrl(person); const url = this.apUtilityService.findBestObjectUrl(person);
@ -793,8 +790,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const uri = getApId(value); const uri = getApId(value);
if (!this.utilityService.isFederationAllowedUri(uri)) { if (!this.utilityService.isFederationAllowedUri(uri)) {
// TODO convert to identifiable error throw new IdentifiableError('590719b3-f51f-48a9-8e7d-6f559ad00e5d', `failed to resolve person ${uri}: host is blocked`);
throw new StatusError(`blocked host: ${uri}`, 451, 'blocked host');
} }
//#region このサーバーに既に登録されていたらそれを返す //#region このサーバーに既に登録されていたらそれを返す
@ -804,8 +800,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
// Bail if local URI doesn't exist // Bail if local URI doesn't exist
if (this.utilityService.isUriLocal(uri)) { if (this.utilityService.isUriLocal(uri)) {
// TODO convert to identifiable error throw new IdentifiableError('efb573fd-6b9e-4912-9348-a02f5603df4f', `failed to resolve person ${uri}: URL is local and does not exist`);
throw new StatusError(`cannot resolve local person: ${uri}`, 400, 'cannot resolve local person');
} }
const unlock = await this.appLockService.getApLock(uri); const unlock = await this.appLockService.getApLock(uri);
@ -859,7 +854,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
}) : null; }) : null;
if (!collection) return; if (!collection) return;
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`featured ${user.featured} is not Collection or OrderedCollection in ${user.uri}`); if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`failed to update user ${user.uri}: featured ${user.featured} is not Collection or OrderedCollection`);
// Resolve to Object(may be Note) arrays // Resolve to Object(may be Note) arrays
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;

View file

@ -21,7 +21,7 @@ export class FileWriterStream extends WritableStream<Uint8Array> {
write: async (chunk, controller) => { write: async (chunk, controller) => {
if (file === null) { if (file === null) {
controller.error(); controller.error();
throw new Error(); throw new Error('file is null');
} }
await file.write(chunk); await file.write(chunk);

View file

@ -8,8 +8,8 @@ export class FastifyReplyError extends Error {
public message: string; public message: string;
public statusCode: number; public statusCode: number;
constructor(statusCode: number, message: string) { constructor(statusCode: number, message: string, cause?: unknown) {
super(message); super(message, cause ? { cause } : undefined);
this.message = message; this.message = message;
this.statusCode = statusCode; this.statusCode = statusCode;
} }

View file

@ -8,6 +8,7 @@
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import { parseBigInt36 } from '@/misc/bigint.js'; import { parseBigInt36 } from '@/misc/bigint.js';
import { IdentifiableError } from '../identifiable-error.js';
export const aidRegExp = /^[0-9a-z]{10}$/; export const aidRegExp = /^[0-9a-z]{10}$/;
@ -26,7 +27,7 @@ function getNoise(): string {
} }
export function genAid(t: number): string { export function genAid(t: number): string {
if (isNaN(t)) throw new Error('Failed to create AID: Invalid Date'); if (isNaN(t)) throw new IdentifiableError('6b73b7d5-9d2b-48b4-821c-ef955efe80ad', 'Failed to create AID: Invalid Date');
counter++; counter++;
return getTime(t) + getNoise(); return getTime(t) + getNoise();
} }

View file

@ -10,6 +10,7 @@
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { parseBigInt36 } from '@/misc/bigint.js'; import { parseBigInt36 } from '@/misc/bigint.js';
import { IdentifiableError } from '../identifiable-error.js';
export const aidxRegExp = /^[0-9a-z]{16}$/; export const aidxRegExp = /^[0-9a-z]{16}$/;
@ -34,7 +35,7 @@ function getNoise(): string {
} }
export function genAidx(t: number): string { export function genAidx(t: number): string {
if (isNaN(t)) throw new Error('Failed to create AIDX: Invalid Date'); if (isNaN(t)) throw new IdentifiableError('6b73b7d5-9d2b-48b4-821c-ef955efe80ad', 'Failed to create AIDX: Invalid Date');
counter++; counter++;
return getTime(t) + nodeId + getNoise(); return getTime(t) + nodeId + getNoise();
} }

View file

@ -15,8 +15,8 @@ export class IdentifiableError extends Error {
*/ */
public readonly isRetryable: boolean; public readonly isRetryable: boolean;
constructor(id: string, message?: string, isRetryable = false, options?: ErrorOptions) { constructor(id: string, message?: string, isRetryable = false, cause?: unknown) {
super(message, options); super(message, cause ? { cause } : undefined);
this.message = message ?? ''; this.message = message ?? '';
this.id = id; this.id = id;
this.isRetryable = isRetryable; this.isRetryable = isRetryable;

View file

@ -7,14 +7,26 @@ import { AbortError, FetchError } from 'node-fetch';
import { UnrecoverableError } from 'bullmq'; import { UnrecoverableError } from 'bullmq';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CaptchaError, captchaErrorCodes } from '@/core/CaptchaService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { ConflictError } from '@/server/SkRateLimiterService.js';
/** /**
* Returns false if the provided value represents a "permanent" error that cannot be retried. * 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. * 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 { export function isRetryableError(e: unknown): boolean {
if (e instanceof AggregateError) return e.errors.every(inner => isRetryableError(inner));
if (e instanceof StatusError) return e.isRetryable; if (e instanceof StatusError) return e.isRetryable;
if (e instanceof IdentifiableError) return e.isRetryable; if (e instanceof IdentifiableError) return e.isRetryable;
if (e instanceof CaptchaError) {
if (e.code === captchaErrorCodes.verificationFailed) return false;
if (e.code === captchaErrorCodes.invalidParameters) return false;
if (e.code === captchaErrorCodes.invalidProvider) return false;
return true;
}
if (e instanceof FastifyReplyError) return false;
if (e instanceof ConflictError) return true;
if (e instanceof UnrecoverableError) return false; if (e instanceof UnrecoverableError) return false;
if (e instanceof AbortError) return true; if (e instanceof AbortError) return true;
if (e instanceof FetchError) return true; if (e instanceof FetchError) return true;

View file

@ -5,23 +5,35 @@
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { CaptchaError } from '@/core/CaptchaService.js';
export function renderInlineError(err: unknown): string { export function renderInlineError(err: unknown): string {
const parts: string[] = [];
renderTo(err, parts);
return parts.join('');
}
function renderTo(err: unknown, parts: string[]): void {
parts.push(printError(err));
if (err instanceof AggregateError) {
for (let i = 0; i < err.errors.length; i++) {
parts.push(` [${i + 1}/${err.errors.length}]: `);
renderTo(err.errors[i], parts);
}
}
if (err instanceof Error) { if (err instanceof Error) {
const text = printError(err);
if (err.cause) { if (err.cause) {
const cause = renderInlineError(err.cause); parts.push(' [caused by]: ');
return `${text} [caused by]: ${cause}`; renderTo(err.cause, parts);
} else { // const cause = renderInlineError(err.cause);
return text; // parts.push(' [caused by]: ', cause);
}
} }
} }
return String(err); function printError(err: unknown): string {
}
function printError(err: Error): string {
if (err instanceof IdentifiableError) { if (err instanceof IdentifiableError) {
if (err.message) { if (err.message) {
return `${err.name} ${err.id}: ${err.message}`; return `${err.name} ${err.id}: ${err.message}`;
@ -40,9 +52,21 @@ function printError(err: Error): string {
} }
} }
if (err instanceof CaptchaError) {
if (err.code.description) {
return `${err.name} ${err.code.description}: ${err.message}`;
} else {
return `${err.name}: ${err.message}`;
}
}
if (err instanceof Error) {
if (err.message) { if (err.message) {
return `${err.name}: ${err.message}`; return `${err.name}: ${err.message}`;
} else { } else {
return err.name; return err.name;
} }
} }
return String(err);
}

View file

@ -9,8 +9,8 @@ export class StatusError extends Error {
public isClientError: boolean; public isClientError: boolean;
public isRetryable: boolean; public isRetryable: boolean;
constructor(message: string, statusCode: number, statusMessage?: string, options?: ErrorOptions) { constructor(message: string, statusCode: number, statusMessage?: string, cause?: unknown) {
super(message, options); super(message, cause ? { cause } : undefined);
this.name = 'StatusError'; this.name = 'StatusError';
this.statusCode = statusCode; this.statusCode = statusCode;
this.statusMessage = statusMessage; this.statusMessage = statusMessage;

View file

@ -133,9 +133,8 @@ export class DeliverProcessorService {
} }
}); });
if (res instanceof StatusError) { if (res instanceof StatusError && !res.isRetryable) {
// 4xx // 4xx
if (!res.isRetryable) {
// 相手が閉鎖していることを明示しているため、配送停止する // 相手が閉鎖していることを明示しているため、配送停止する
if (job.data.isSharedInbox && res.statusCode === 410) { if (job.data.isSharedInbox && res.statusCode === 410) {
this.federatedInstanceService.fetchOrRegister(host).then(i => { this.federatedInstanceService.fetchOrRegister(host).then(i => {
@ -146,10 +145,6 @@ export class DeliverProcessorService {
throw new Bull.UnrecoverableError(`${host} is gone`); throw new Bull.UnrecoverableError(`${host} is gone`);
} }
throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`);
}
// 5xx etc.
throw new Error(`${res.statusCode} ${res.statusMessage}`);
} else { } else {
// DNS error, socket error, timeout ... // DNS error, socket error, timeout ...
throw res; throw res;

View file

@ -228,7 +228,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
const ldHost = this.utilityService.extractDbHost(authUser.user.uri); const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
if (!this.utilityService.isFederationAllowedHost(ldHost)) { if (!this.utilityService.isFederationAllowedHost(ldHost)) {
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); throw new Bull.UnrecoverableError(`skip: request host is blocked: ${ldHost}`);
} }
} else { } else {
throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`); throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`);

View file

@ -133,7 +133,7 @@ export class ScheduleNotePostProcessorService {
reason: renderInlineError(err), reason: renderInlineError(err),
}); });
await this.noteScheduleRepository.remove(data); await this.noteScheduleRepository.remove(data);
this.logger.error(`Scheduled note failed:`, err); this.logger.error(`Scheduled note failed: ${renderInlineError(err)}`);
throw err; throw err;
}); });
await this.noteScheduleRepository.remove(data); await this.noteScheduleRepository.remove(data);

View file

@ -71,14 +71,9 @@ export class SystemWebhookDeliverProcessorService {
latestStatus: res instanceof StatusError ? res.statusCode : 1, latestStatus: res instanceof StatusError ? res.statusCode : 1,
}); });
if (res instanceof StatusError) { if (res instanceof StatusError && !res.isRetryable) {
// 4xx // 4xx
if (!res.isRetryable) {
throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`);
}
// 5xx etc.
throw new Error(`${res.statusCode} ${res.statusMessage}`);
} else { } else {
// DNS error, socket error, timeout ... // DNS error, socket error, timeout ...
throw res; throw res;

View file

@ -69,14 +69,9 @@ export class UserWebhookDeliverProcessorService {
latestStatus: res instanceof StatusError ? res.statusCode : 1, latestStatus: res instanceof StatusError ? res.statusCode : 1,
}); });
if (res instanceof StatusError) { if (res instanceof StatusError && !res.isRetryable) {
// 4xx // 4xx
if (!res.isRetryable) {
throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`);
}
// 5xx etc.
throw new Error(`${res.statusCode} ${res.statusMessage}`);
} else { } else {
// DNS error, socket error, timeout ... // DNS error, socket error, timeout ...
throw res; throw res;

View file

@ -389,7 +389,7 @@ function createLimitKey(limit: ParsedLimit, actor: string, value: string): strin
return `rl_${actor}_${limit.key}_${value}`; return `rl_${actor}_${limit.key}_${value}`;
} }
class ConflictError extends Error {} export class ConflictError extends Error {}
interface LimitCounter { interface LimitCounter {
timestamp: number; timestamp: number;

View file

@ -205,37 +205,37 @@ export class SigninApiService {
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableFC && this.meta.fcSecretKey) { if (this.meta.enableFC && this.meta.fcSecretKey) {
await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => { await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableTestcaptcha) { if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
} }

View file

@ -83,37 +83,37 @@ export class SignupApiService {
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableFC && this.meta.fcSecretKey) { if (this.meta.enableFC && this.meta.fcSecretKey) {
await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => { await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
if (this.meta.enableTestcaptcha) { if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err); throw new FastifyReplyError(400, String(err), err);
}); });
} }
} }
@ -287,7 +287,7 @@ export class SignupApiService {
token: secret, token: secret,
}; };
} catch (err) { } catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); throw new FastifyReplyError(400, String(err), err);
} }
} }
} }
@ -356,7 +356,7 @@ export class SignupApiService {
return this.signinService.signin(request, reply, account as MiLocalUser); return this.signinService.signin(request, reply, account as MiLocalUser);
} catch (err) { } catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); throw new FastifyReplyError(400, String(err), err);
} }
} }
} }

View file

@ -68,11 +68,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private readonly moderationLogService: ModerationLogService, private readonly moderationLogService: ModerationLogService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
try { if (!URL.canParse(ps.inbox)) throw new ApiError(meta.errors.invalidUrl);
if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only'); if (new URL(ps.inbox).protocol !== 'https:') throw new ApiError(meta.errors.invalidUrl);
} catch {
throw new ApiError(meta.errors.invalidUrl);
}
await this.moderationLogService.log(me, 'addRelay', { await this.moderationLogService.log(me, 'addRelay', {
inbox: ps.inbox, inbox: ps.inbox,

View file

@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
))).filter(x => x != null); ))).filter(x => x != null);
if (files.length === 0) { if (files.length === 0) {
throw new Error(); throw new Error('no files specified');
} }
const post = await this.galleryPostsRepository.insertOne(new MiGalleryPost({ const post = await this.galleryPostsRepository.insertOne(new MiGalleryPost({

View file

@ -73,7 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
))).filter(x => x != null); ))).filter(x => x != null);
if (files.length === 0) { if (files.length === 0) {
throw new Error(); throw new Error('no files');
} }
} }

View file

@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
try { try {
await this.userAuthService.twoFactorAuthenticate(profile, token); await this.userAuthService.twoFactorAuthenticate(profile, token);
} catch (e) { } catch (e) {
throw new Error('authentication failed'); throw new Error('authentication failed', { cause: e });
} }
} }

View file

@ -8,6 +8,9 @@ import { AbortError } from 'node-fetch';
import { isRetryableError } from '@/misc/is-retryable-error.js'; import { isRetryableError } from '@/misc/is-retryable-error.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CaptchaError, captchaErrorCodes } from '@/core/CaptchaService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { ConflictError } from '@/server/SkRateLimiterService.js';
describe(isRetryableError, () => { describe(isRetryableError, () => {
it('should return true for retryable StatusError', () => { it('should return true for retryable StatusError', () => {
@ -55,6 +58,78 @@ describe(isRetryableError, () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it('should return false for CaptchaError with verificationFailed', () => {
const error = new CaptchaError(captchaErrorCodes.verificationFailed, 'verificationFailed');
const result = isRetryableError(error);
expect(result).toBeFalsy();
});
it('should return false for CaptchaError with invalidProvider', () => {
const error = new CaptchaError(captchaErrorCodes.invalidProvider, 'invalidProvider');
const result = isRetryableError(error);
expect(result).toBeFalsy();
});
it('should return false for CaptchaError with invalidParameters', () => {
const error = new CaptchaError(captchaErrorCodes.invalidParameters, 'invalidParameters');
const result = isRetryableError(error);
expect(result).toBeFalsy();
});
it('should return true for CaptchaError with noResponseProvided', () => {
const error = new CaptchaError(captchaErrorCodes.noResponseProvided, 'noResponseProvided');
const result = isRetryableError(error);
expect(result).toBeTruthy();
});
it('should return true for CaptchaError with requestFailed', () => {
const error = new CaptchaError(captchaErrorCodes.requestFailed, 'requestFailed');
const result = isRetryableError(error);
expect(result).toBeTruthy();
});
it('should return true for CaptchaError with unknown', () => {
const error = new CaptchaError(captchaErrorCodes.unknown, 'unknown');
const result = isRetryableError(error);
expect(result).toBeTruthy();
});
it('should return true for CaptchaError with any other', () => {
const error = new CaptchaError(Symbol('temp'), 'unknown');
const result = isRetryableError(error);
expect(result).toBeTruthy();
});
it('should return false for FastifyReplyError', () => {
const error = new FastifyReplyError(400, 'test error');
const result = isRetryableError(error);
expect(result).toBeFalsy();
});
it('should return true for ConflictError', () => {
const error = new ConflictError('test error');
const result = isRetryableError(error);
expect(result).toBeTruthy();
});
it('should return true for AggregateError when all inners are retryable', () => {
const error = new AggregateError([
new ConflictError(),
new ConflictError(),
]);
const result = isRetryableError(error);
expect(result).toBeTruthy();
});
it('should return true for AggregateError when any error is not retryable', () => {
const error = new AggregateError([
new ConflictError(),
new StatusError('test err', 400),
]);
const result = isRetryableError(error);
expect(result).toBeFalsy();
});
const nonErrorInputs = [ const nonErrorInputs = [
[null, 'null'], [null, 'null'],
[undefined, 'undefined'], [undefined, 'undefined'],