diff --git a/CHANGES.md b/CHANGES.md index c15c4eb6..c59ff9b5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -123,6 +123,22 @@ To be released. with the same `orderingKey` are guaranteed to be delivered in order to each recipient server. + - Added `Federatable.setOutboxPermanentFailureHandler()` method to handle + permanent delivery failures (such as `410 Gone` or `404 Not Found`) when + sending activities to remote inboxes. This allows applications to clean + up unreachable followers and avoid future delivery attempts to permanently + failed inboxes. [[#548], [#559]] + + - Added `permanentFailureStatusCodes` option to `FederationOptions` to + configure which HTTP status codes are treated as permanent delivery + failures. By default, `404` and `410` are treated as permanent failures. + [[#548], [#559]] + + - Added `SendActivityError` class, a structured error that is thrown when + an activity fails to send to a remote inbox. It includes the HTTP status + code, the inbox URL, and the response body, making it easier to + programmatically handle delivery errors. [[#548], [#559]] + [#280]: https://github.com/fedify-dev/fedify/issues/280 [#366]: https://github.com/fedify-dev/fedify/issues/366 [#376]: https://github.com/fedify-dev/fedify/issues/376 @@ -144,6 +160,8 @@ To be released. [#538]: https://github.com/fedify-dev/fedify/issues/538 [#540]: https://github.com/fedify-dev/fedify/pull/540 [#544]: https://github.com/fedify-dev/fedify/pull/544 +[#548]: https://github.com/fedify-dev/fedify/issues/548 +[#559]: https://github.com/fedify-dev/fedify/pull/559 [#560]: https://github.com/fedify-dev/fedify/issues/560 ### @fedify/cli diff --git a/docs/manual/federation.md b/docs/manual/federation.md index 64537d3a..05ad4693 100644 --- a/docs/manual/federation.md +++ b/docs/manual/federation.md @@ -339,6 +339,33 @@ Defaults to `"rfc9421"`. [HTTP Signatures]: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12 [RFC 9421]: https://www.rfc-editor.org/rfc/rfc9421 +### `permanentFailureStatusCodes` + +*This API is available since Fedify 2.0.0.* + +HTTP status codes that should be treated as permanent delivery failures. +When an inbox returns one of these codes, the delivery will not be retried +and the permanent failure handler (if registered via +`~Federatable.setOutboxPermanentFailureHandler()`) will be called. + +By default, `[404, 410]`. + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, InProcessMessageQueue } from "@fedify/fedify"; + +const federation = createFederation({ + // Omitted for brevity; see the related section for details. + queue: new InProcessMessageQueue(), + permanentFailureStatusCodes: [404, 410, 451], +}); +~~~~ + +See also the +[*Permanent delivery failure handling*](./send.md#permanent-delivery-failure-handling) +section in the *Sending activities* page for how to register a handler for +permanent delivery failures. + ### `outboxRetryPolicy` *This API is available since Fedify 0.12.0.* diff --git a/docs/manual/send.md b/docs/manual/send.md index d8a08616..9b06ccc6 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -833,6 +833,89 @@ const federation = createFederation({ > activity, because the delivery is retried according to the backoff schedule > until it succeeds or reaches the maximum retry count. +### Permanent delivery failure handling + +*This API is available since Fedify 2.0.0.* + +When a remote inbox returns an HTTP status code that indicates a permanent +failure (such as `410 Gone` or `404 Not Found`), there is no point in retrying +the delivery. Fedify automatically skips retries for permanent failures and +allows you to handle them by registering a permanent failure handler: + +~~~~ typescript twoslash +// @noErrors: 2304 2345 +import { createFederation, InProcessMessageQueue } from "@fedify/fedify"; + +const federation = createFederation({ + // Omitted for brevity; see the related section for details. + queue: new InProcessMessageQueue(), +}); + +federation.setOutboxPermanentFailureHandler(async (ctx, values) => { + console.warn( + `Inbox ${values.inbox.href} permanently failed ` + + `with status ${values.statusCode}` + ); + // Remove followers associated with this inbox: + for (const actorId of values.actorIds) { + await removeFollower(actorId); + } +}); +~~~~ + +The handler receives the following information: + +`values.inbox` +: The inbox URL that returned the permanent failure. + +`values.activity` +: The `Activity` object that failed to deliver. + +`values.error` +: The `SendActivityError` that was thrown. + +`values.statusCode` +: The HTTP status code returned by the inbox (e.g., `404` or `410`). + +`values.actorIds` +: The actor IDs that were supposed to receive the activity at this inbox. + When `preferSharedInbox: true` is used, a single inbox URL may represent + multiple followers. + +By default, the following HTTP status codes are treated as permanent failures: + + - `404 Not Found` — the inbox no longer exists + - `410 Gone` — the inbox is explicitly marked as gone + +You can customize which status codes are treated as permanent failures using +the `permanentFailureStatusCodes` option in `FederationOptions`: + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, InProcessMessageQueue } from "@fedify/fedify"; + +const federation = createFederation({ + // Omitted for brevity; see the related section for details. + queue: new InProcessMessageQueue(), + permanentFailureStatusCodes: [404, 410, 451], +}); +~~~~ + +See also the +[`permanentFailureStatusCodes`](./federation.md#permanentfailurestatuscodes) +option in the *Federation* section for more details. + +> [!NOTE] +> The permanent failure handler is called only once for each permanent +> failure, and delivery is not retried afterwards. This is different from +> `onOutboxError`, which can be called multiple times due to retries. +> +> Even if no handler is registered, permanent failures will still skip retries. + +> [!TIP] +> If any errors are thrown in the permanent failure handler, they are caught, +> logged, and ignored, similar to `onOutboxError`. + HTTP Signatures --------------- diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 843d6f2d..d4cd5b18 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -27,6 +27,7 @@ import type { NodeInfoDispatcher, ObjectAuthorizePredicate, ObjectDispatcher, + OutboxPermanentFailureHandler, SharedInboxKeyDispatcher, WebFingerLinksDispatcher, } from "./callback.ts"; @@ -106,6 +107,7 @@ export class FederationBuilderImpl inboxListeners?: InboxListenerSet; inboxErrorHandler?: InboxErrorHandler; sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher; + outboxPermanentFailureHandler?: OutboxPermanentFailureHandler; idempotencyStrategy?: | IdempotencyStrategy | IdempotencyKeyCallback; @@ -185,6 +187,7 @@ export class FederationBuilderImpl f.inboxListeners = this.inboxListeners?.clone(); f.inboxErrorHandler = this.inboxErrorHandler; f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher; + f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler; f.idempotencyStrategy = this.idempotencyStrategy; return f; } @@ -1535,6 +1538,12 @@ export class FederationBuilderImpl return path; } + setOutboxPermanentFailureHandler( + handler: OutboxPermanentFailureHandler, + ): void { + this.outboxPermanentFailureHandler = handler; + } + /** * Converts a name (string or symbol) to a unique string identifier. * For symbols, generates and caches a UUID if not already present. diff --git a/packages/fedify/src/federation/callback.ts b/packages/fedify/src/federation/callback.ts index 6bf5a6c2..600a01ed 100644 --- a/packages/fedify/src/federation/callback.ts +++ b/packages/fedify/src/federation/callback.ts @@ -3,7 +3,7 @@ import type { Link } from "@fedify/webfinger"; import type { NodeInfo } from "../nodeinfo/types.ts"; import type { PageItems } from "./collection.ts"; import type { Context, InboxContext, RequestContext } from "./context.ts"; -import type { SenderKeyPair } from "./send.ts"; +import type { SendActivityError, SenderKeyPair } from "./send.ts"; /** * A callback that dispatches a {@link NodeInfo} object. @@ -232,6 +232,44 @@ export type OutboxErrorHandler = ( activity: Activity | null, ) => void | Promise; +/** + * A callback that handles permanent delivery failures when sending activities + * to remote inboxes. + * + * This handler is called when an inbox returns an HTTP status code that + * indicates permanent failure (such as `410 Gone` or `404 Not Found`), + * allowing the application to clean up followers that are no longer reachable. + * + * Unlike {@link OutboxErrorHandler}, which is called for every delivery failure + * (including retries), this handler is called only once for permanent failures, + * after which delivery is not retried. + * + * If any errors are thrown in this callback, they are caught, logged, + * and ignored. + * + * @template TContextData The context data to pass to the {@link Context}. + * @param context The context. + * @param values The delivery failure information. + * @since 2.0.0 + */ +export type OutboxPermanentFailureHandler = ( + context: Context, + values: { + /** The inbox URL that failed. */ + readonly inbox: URL; + /** The activity that failed to deliver. */ + readonly activity: Activity; + /** The error that occurred. */ + readonly error: SendActivityError; + /** The HTTP status code returned by the inbox. */ + readonly statusCode: number; + /** + * The actor IDs that were supposed to receive the activity at this inbox. + */ + readonly actorIds: readonly URL[]; + }, +) => void | Promise; + /** * A callback that determines if a request is authorized or not. * diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 64431410..34a1b446 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -31,6 +31,7 @@ import type { ObjectAuthorizePredicate, ObjectDispatcher, OutboxErrorHandler, + OutboxPermanentFailureHandler, SharedInboxKeyDispatcher, WebFingerLinksDispatcher, } from "./callback.ts"; @@ -677,6 +678,25 @@ export interface Federatable { RequestContext, TContextData >; + + /** + * Registers a handler for permanent delivery failures. + * + * This handler is called when an inbox returns an HTTP status code + * that indicates permanent failure (`410 Gone`, `404 Not Found`, etc.), + * allowing the application to clean up followers that are no longer + * reachable. + * + * Unlike `onOutboxError`, which is called for every delivery failure + * (including retries), this handler is called only once for permanent + * failures, after which delivery is not retried. + * + * @param handler A callback to handle permanent failures. + * @since 2.0.0 + */ + setOutboxPermanentFailureHandler( + handler: OutboxPermanentFailureHandler, + ): void; } /** @@ -879,6 +899,18 @@ export interface FederationOptions { */ onOutboxError?: OutboxErrorHandler; + /** + * HTTP status codes that should be treated as permanent delivery failures. + * When an inbox returns one of these codes, the delivery will not be retried + * and the permanent failure handler (if registered via + * {@link Federatable.setOutboxPermanentFailureHandler}) will be called. + * + * By default, `[404, 410]`. + * + * @since 2.0.0 + */ + permanentFailureStatusCodes?: readonly number[]; + /** * The time window for verifying HTTP Signatures of incoming requests. If the * request is older or newer than this window, it is rejected. Or if it is diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index a49a7550..aa673f58 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1880,6 +1880,286 @@ test("FederationImpl.processQueuedTask()", async (t) => { }); }); +test("FederationImpl.processQueuedTask() permanent failure", async (t) => { + fetchMock.spyGlobal(); + + fetchMock.post("https://gone.example/inbox", { + status: 410, + body: "Gone", + }); + fetchMock.post("https://notfound.example/inbox", { + status: 404, + body: "Not Found", + }); + fetchMock.post("https://error.example/inbox", { + status: 500, + body: "Internal Server Error", + }); + fetchMock.post("https://legal.example/inbox", { + status: 451, + body: "Unavailable For Legal Reasons", + }); + + interface PermanentFailureSetup { + federation: FederationImpl; + queuedMessages: Message[]; + } + + function setup( + options: { + permanentFailureStatusCodes?: readonly number[]; + nativeRetrial?: boolean; + } = {}, + ): PermanentFailureSetup { + const kv = new MemoryKvStore(); + const queuedMessages: Message[] = []; + const queue: MessageQueue = { + ...(options.nativeRetrial ? { nativeRetrial: true } : {}), + enqueue(message, _options) { + queuedMessages.push(message); + return Promise.resolve(); + }, + listen(_handler, _options) { + return Promise.resolve(); + }, + }; + const federation = new FederationImpl({ + kv, + queue, + ...(options.permanentFailureStatusCodes + ? { permanentFailureStatusCodes: options.permanentFailureStatusCodes } + : {}), + }); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); + return { federation, queuedMessages }; + } + + function createOutboxMessage( + inbox: string, + activityId: string, + actorIds?: string[], + ): OutboxMessage { + return { + type: "outbox", + id: crypto.randomUUID(), + baseUrl: "https://example.com", + keys: [], + activity: { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Create", + id: activityId, + actor: "https://example.com/users/alice", + object: { type: "Note", content: "test" }, + }, + activityType: "https://www.w3.org/ns/activitystreams#Create", + inbox, + sharedInbox: false, + ...(actorIds != null ? { actorIds } : {}), + started: new Date().toISOString(), + attempt: 0, + headers: {}, + traceContext: {}, + }; + } + + await t.step("410 Gone triggers permanent failure handler", async () => { + const { federation, queuedMessages } = setup(); + let handlerCalled = false; + let handlerValues: Record = {}; + federation.setOutboxPermanentFailureHandler((_ctx, values) => { + handlerCalled = true; + handlerValues = { ...values }; + }); + + await federation.processQueuedTask( + undefined, + createOutboxMessage( + "https://gone.example/inbox", + "https://example.com/activity/1", + [ + "https://gone.example/users/bob", + "https://gone.example/users/charlie", + ], + ), + ); + assert(handlerCalled, "Permanent failure handler should be called"); + assertEquals( + handlerValues.inbox, + new URL("https://gone.example/inbox"), + ); + assertEquals(handlerValues.statusCode, 410); + assertInstanceOf(handlerValues.activity, vocab.Create); + assertEquals(handlerValues.actorIds, [ + new URL("https://gone.example/users/bob"), + new URL("https://gone.example/users/charlie"), + ]); + // Should NOT be re-enqueued for retry + assertEquals(queuedMessages, []); + }); + + await t.step("404 Not Found triggers permanent failure handler", async () => { + const { federation, queuedMessages } = setup(); + let handlerCalled = false; + let handlerStatusCode = 0; + federation.setOutboxPermanentFailureHandler((_ctx, values) => { + handlerCalled = true; + handlerStatusCode = values.statusCode; + }); + + await federation.processQueuedTask( + undefined, + createOutboxMessage( + "https://notfound.example/inbox", + "https://example.com/activity/2", + ["https://notfound.example/users/bob"], + ), + ); + assert(handlerCalled, "Permanent failure handler should be called"); + assertEquals(handlerStatusCode, 404); + // Should NOT be re-enqueued for retry + assertEquals(queuedMessages, []); + }); + + await t.step( + "500 error does NOT trigger permanent failure handler", + async () => { + const { federation, queuedMessages } = setup(); + let handlerCalled = false; + federation.setOutboxPermanentFailureHandler(() => { + handlerCalled = true; + }); + + await federation.processQueuedTask( + undefined, + createOutboxMessage( + "https://error.example/inbox", + "https://example.com/activity/3", + ["https://error.example/users/bob"], + ), + ); + assertFalse( + handlerCalled, + "Permanent failure handler should NOT be called", + ); + // Should be re-enqueued for retry (normal retry behavior) + assertEquals(queuedMessages.length, 1); + assertEquals((queuedMessages[0] as OutboxMessage).attempt, 1); + }, + ); + + await t.step("custom permanentFailureStatusCodes", async () => { + const { federation, queuedMessages } = setup({ + permanentFailureStatusCodes: [404, 410, 451], + }); + let handlerCalled = false; + let handlerStatusCode = 0; + federation.setOutboxPermanentFailureHandler((_ctx, values) => { + handlerCalled = true; + handlerStatusCode = values.statusCode; + }); + + await federation.processQueuedTask( + undefined, + createOutboxMessage( + "https://legal.example/inbox", + "https://example.com/activity/4", + ["https://legal.example/users/bob"], + ), + ); + assert(handlerCalled, "Permanent failure handler should be called for 451"); + assertEquals(handlerStatusCode, 451); + // Should NOT be re-enqueued for retry + assertEquals(queuedMessages, []); + }); + + await t.step("handler exception is caught and logged", async () => { + const { federation, queuedMessages } = setup(); + federation.setOutboxPermanentFailureHandler(() => { + throw new Error("Handler error that should be ignored"); + }); + + // Should not throw even though the handler throws + await federation.processQueuedTask( + undefined, + createOutboxMessage( + "https://gone.example/inbox", + "https://example.com/activity/5", + ["https://gone.example/users/bob"], + ), + ); + // Should NOT be re-enqueued for retry + assertEquals(queuedMessages, []); + }); + + await t.step( + "permanent failure skips retry without handler registered", + async () => { + const { federation, queuedMessages } = setup(); + // No handler registered + + await federation.processQueuedTask( + undefined, + createOutboxMessage( + "https://gone.example/inbox", + "https://example.com/activity/6", + [], + ), + ); + // Should NOT be re-enqueued for retry even without a handler + assertEquals(queuedMessages, []); + }, + ); + + await t.step( + "nativeRetrial: permanent failure does not re-throw", + async () => { + const { federation, queuedMessages } = setup({ + nativeRetrial: true, + }); + let handlerCalled = false; + federation.setOutboxPermanentFailureHandler(() => { + handlerCalled = true; + }); + + // Should NOT throw (unlike non-permanent failures with nativeRetrial) + await federation.processQueuedTask( + undefined, + createOutboxMessage( + "https://gone.example/inbox", + "https://example.com/activity/7", + ["https://gone.example/users/bob"], + ), + ); + assert(handlerCalled, "Permanent failure handler should be called"); + assertEquals(queuedMessages, []); + }, + ); + + await t.step( + "actorIds missing from message defaults to empty array", + async () => { + const { federation, queuedMessages } = setup(); + let handlerActorIds: readonly URL[] = []; + federation.setOutboxPermanentFailureHandler((_ctx, values) => { + handlerActorIds = values.actorIds; + }); + + await federation.processQueuedTask( + undefined, + // No actorIds field (simulating old message format) + createOutboxMessage( + "https://gone.example/inbox", + "https://example.com/activity/8", + ), + ); + assertEquals(handlerActorIds, []); + assertEquals(queuedMessages, []); + }, + ); + + fetchMock.hardReset(); +}); + test("ContextImpl.lookupObject()", async (t) => { // Note that this test only checks if allowPrivateAddress option affects // the ContextImpl.lookupObject() method. Other aspects of the method are diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 890b240e..826b546f 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -106,7 +106,12 @@ import type { } from "./queue.ts"; import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts"; import { RouterError } from "./router.ts"; -import { extractInboxes, sendActivity, type SenderKeyPair } from "./send.ts"; +import { + extractInboxes, + sendActivity, + SendActivityError, + type SenderKeyPair, +} from "./send.ts"; import { handleWebFinger } from "./webfinger.ts"; /** @@ -230,6 +235,7 @@ export class FederationImpl allowPrivateAddress: boolean; userAgent?: GetUserAgentOptions | string; onOutboxError?: OutboxErrorHandler; + permanentFailureStatusCodes: readonly number[]; signatureTimeWindow: Temporal.Duration | Temporal.DurationLike | false; skipSignatureVerification: boolean; outboxRetryPolicy: RetryPolicy; @@ -369,6 +375,8 @@ export class FederationImpl })); this.userAgent = userAgent; this.onOutboxError = options.onOutboxError; + this.permanentFailureStatusCodes = options.permanentFailureStatusCodes ?? + [404, 410]; this.signatureTimeWindow = options.signatureTimeWindow ?? { hours: 1 }; this.skipSignatureVerification = options.skipSignatureVerification ?? false; this.outboxRetryPolicy = options.outboxRetryPolicy ?? @@ -640,13 +648,64 @@ export class FederationImpl tracerProvider: this.tracerProvider, }); try { - this.onOutboxError?.(error as Error, activity); + await this.onOutboxError?.(error as Error, activity); } catch (error) { logger.error( "An unexpected error occurred in onError handler:\n{error}", { ...logData, error }, ); } + + // Check if the error is a permanent delivery failure + if ( + error instanceof SendActivityError && + this.permanentFailureStatusCodes.includes(error.statusCode) + ) { + logger.warn( + "Permanent delivery failure for activity {activityId} to " + + "{inbox} ({status}); not retrying.", + { + ...logData, + status: error.statusCode, + }, + ); + if (this.outboxPermanentFailureHandler != null) { + const ctx = this.#createContext( + new URL(message.baseUrl), + _, + { + documentLoader: this.documentLoaderFactory(loaderOptions), + }, + ); + try { + await this.outboxPermanentFailureHandler(ctx, { + inbox: new URL(message.inbox), + activity, + error, + statusCode: error.statusCode, + actorIds: (message.actorIds ?? []).flatMap((id) => { + try { + return [new URL(id)]; + } catch { + logger.warn( + "Invalid actorId URL in OutboxMessage: {id}", + { id }, + ); + return []; + } + }), + }); + } catch (handlerError) { + logger.error( + "An unexpected error occurred in " + + "outboxPermanentFailureHandler:\n{error}", + { ...logData, error: handlerError }, + ); + } + } + return; + } + // Skip retry logic if the message queue backend handles retries automatically if (this.outboxQueue?.nativeRetrial) { logger.error( @@ -1115,6 +1174,7 @@ export class FederationImpl activityType: getTypeId(activity).href, inbox, sharedInbox: inboxes[inbox].sharedInbox, + actorIds: [...inboxes[inbox].actorIds], started: new Date().toISOString(), attempt: 0, headers: collectionSync == null ? {} : { diff --git a/packages/fedify/src/federation/mod.ts b/packages/fedify/src/federation/mod.ts index 161eeb0c..62f21a2c 100644 --- a/packages/fedify/src/federation/mod.ts +++ b/packages/fedify/src/federation/mod.ts @@ -25,7 +25,7 @@ export * from "./mq.ts"; export type { Message } from "./queue.ts"; export * from "./retry.ts"; export * from "./router.ts"; -export { type SenderKeyPair } from "./send.ts"; +export { SendActivityError, type SenderKeyPair } from "./send.ts"; export { handleWebFinger, type WebFingerHandlerParameters, diff --git a/packages/fedify/src/federation/queue.ts b/packages/fedify/src/federation/queue.ts index 358f650c..96ad36c0 100644 --- a/packages/fedify/src/federation/queue.ts +++ b/packages/fedify/src/federation/queue.ts @@ -43,6 +43,7 @@ export interface OutboxMessage { readonly activityType: string; readonly inbox: string; readonly sharedInbox: boolean; + readonly actorIds?: readonly string[]; readonly started: string; readonly attempt: number; readonly headers: Readonly>; diff --git a/packages/fedify/src/federation/send.test.ts b/packages/fedify/src/federation/send.test.ts index e5a31bc4..6f3fbda2 100644 --- a/packages/fedify/src/federation/send.test.ts +++ b/packages/fedify/src/federation/send.test.ts @@ -16,6 +16,7 @@ import { assert, assertEquals, assertFalse, + assertInstanceOf, assertNotEquals, assertRejects, } from "@std/assert"; @@ -29,7 +30,7 @@ import { rsaPublicKey2, } from "../testing/keys.ts"; -import { extractInboxes, sendActivity } from "./send.ts"; +import { extractInboxes, sendActivity, SendActivityError } from "./send.ts"; test("extractInboxes()", () => { const recipients: Actor[] = [ @@ -259,6 +260,85 @@ test("sendActivity()", async (t) => { ); }); + await t.step("failure throws SendActivityError", async () => { + const activity: unknown = { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://example.com/activity", + "actor": "https://example.com/person", + }; + try { + await sendActivity({ + activity, + activityId: "https://example.com/activity", + keys: [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + inbox: new URL("https://example.com/inbox2"), + }); + assert(false, "Should have thrown"); + } catch (e) { + assertInstanceOf(e, SendActivityError); + assertEquals(e.statusCode, 500); + assertEquals(e.inbox, new URL("https://example.com/inbox2")); + assertEquals(e.responseBody, "something went wrong"); + } + }); + + fetchMock.post("https://example.com/inbox-gone", { + status: 410, + body: "Gone", + }); + + await t.step("410 Gone throws SendActivityError", async () => { + const activity: unknown = { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://example.com/activity", + "actor": "https://example.com/person", + }; + try { + await sendActivity({ + activity, + activityId: "https://example.com/activity", + keys: [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + inbox: new URL("https://example.com/inbox-gone"), + }); + assert(false, "Should have thrown"); + } catch (e) { + assertInstanceOf(e, SendActivityError); + assertEquals(e.statusCode, 410); + assertEquals(e.inbox, new URL("https://example.com/inbox-gone")); + assertEquals(e.responseBody, "Gone"); + } + }); + + fetchMock.post("https://example.com/inbox-notfound", { + status: 404, + body: "Not Found", + }); + + await t.step("404 Not Found throws SendActivityError", async () => { + const activity: unknown = { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://example.com/activity", + "actor": "https://example.com/person", + }; + try { + await sendActivity({ + activity, + activityId: "https://example.com/activity", + keys: [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + inbox: new URL("https://example.com/inbox-notfound"), + }); + assert(false, "Should have thrown"); + } catch (e) { + assertInstanceOf(e, SendActivityError); + assertEquals(e.statusCode, 404); + assertEquals(e.inbox, new URL("https://example.com/inbox-notfound")); + assertEquals(e.responseBody, "Not Found"); + } + }); + fetchMock.hardReset(); }); diff --git a/packages/fedify/src/federation/send.ts b/packages/fedify/src/federation/send.ts index a1c6a9b6..9db6180a 100644 --- a/packages/fedify/src/federation/send.ts +++ b/packages/fedify/src/federation/send.ts @@ -263,9 +263,12 @@ async function sendActivityInternal( error, }, ); - throw new Error( + throw new SendActivityError( + inbox, + response.status, `Failed to send activity ${activityId} to ${inbox.href} ` + `(${response.status} ${response.statusText}):\n${error}`, + error, ); } @@ -276,3 +279,46 @@ async function sendActivityInternal( "activitypub.activity.id": activityId ?? "", }); } + +/** + * An error that is thrown when an activity fails to send to a remote inbox. + * It contains structured information about the failure, including the HTTP + * status code, the inbox URL, and the response body. + * @since 2.0.0 + */ +export class SendActivityError extends Error { + /** + * The inbox URL that the activity was being sent to. + */ + readonly inbox: URL; + + /** + * The HTTP status code returned by the inbox. + */ + readonly statusCode: number; + + /** + * The response body from the inbox, if any. + */ + readonly responseBody: string; + + /** + * Creates a new {@link SendActivityError}. + * @param inbox The inbox URL. + * @param statusCode The HTTP status code. + * @param message The error message. + * @param responseBody The response body. + */ + constructor( + inbox: URL, + statusCode: number, + message: string, + responseBody: string, + ) { + super(message); + this.name = "SendActivityError"; + this.inbox = inbox; + this.statusCode = statusCode; + this.responseBody = responseBody; + } +} diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 9749d6d1..a3b4d071 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -352,6 +352,10 @@ class MockFederation implements Federation { }; } + setOutboxPermanentFailureHandler(_handler: any): void { + // Mock implementation - no-op + } + // deno-lint-ignore require-await async startQueue( contextData: TContextData,