Unverified Commit cbc8aca7 authored by Hong Minhee's avatar Hong Minhee
Browse files

withIdempotency() for custom activity idempotency

Addresses issue #441 where activities with the same ID sent to different
inboxes were incorrectly deduplicated globally instead of per-inbox.

- Add IdempotencyStrategy type with "global", "per-origin", "per-inbox" options
- Add IdempotencyKeyCallback type for custom deduplication strategies
- Add InboxListenerSetters.withIdempotency() method
- Implement three built-in strategies:
  * "per-origin": deduplicate per receiving server (current default)
  * "per-inbox": deduplicate per inbox (standard ActivityPub, future default)
  * "global": deduplicate globally across all inboxes
- Add comprehensive test coverage for all strategies
- Add documentation section in docs/manual/inbox.md
- Include deprecation warning when using default strategy
- Cache processed activities for 24 hours using existing KV store
- Maintain backward compatibility with "per-origin" default

The default will change from "per-origin" to "per-inbox" in Fedify 2.0
to align with standard ActivityPub behavior.

Closes https://github.com/fedify-dev/fedify/issues/441



Co-Authored-By: default avatarClaude <noreply@anthropic.com>
parent 45b856c2
Loading
Loading
Loading
Loading
+18 −2
Original line number Diff line number Diff line
@@ -31,6 +31,18 @@ To be released.
     -  Internal trust tracking system maintains security context throughout
        object lifecycles (construction, cloning, and property access).

 -  Added `withIdempotency()` method to configure activity idempotency
    strategies for inbox processing.  This addresses issue [#441] where
    activities with the same ID sent to different inboxes were incorrectly
    deduplicated globally instead of per-inbox.  [[#441]]

     -  Added `IdempotencyStrategy` type.
     -  Added `IdempotencyKeyCallback` type.
     -  Added `InboxListenerSetters.withIdempotency()` method.
     -  By default, `"per-origin"` strategy is used for backward compatibility.
        This will change to `"per-inbox"` in Fedify 2.0.  We recommend
        explicitly setting the strategy to avoid unexpected behavior changes.

 -  Fixed handling of ActivityPub objects containing relative URLs.  The
    Activity Vocabulary classes now automatically resolve relative URLs by
    inferring the base URL from the object's `@id` or document URL, eliminating
@@ -111,6 +123,7 @@ To be released.
[#429]: https://github.com/fedify-dev/fedify/issues/429
[#431]: https://github.com/fedify-dev/fedify/pull/431
[#440]: https://github.com/fedify-dev/fedify/issues/440
[#441]: https://github.com/fedify-dev/fedify/issues/441
[#443]: https://github.com/fedify-dev/fedify/pull/443

### @fedify/cli
@@ -254,7 +267,7 @@ Released on September 17, 2025.
Version 1.8.10
--------------

Released on Steptember 17, 2025.
Released on September 17, 2025.

### @fedify/fedify

@@ -5221,4 +5234,7 @@ Version 0.1.0
Initial release.  Released on March 8, 2024.

<!-- cSpell: ignore Dogeon Fabien Wressell Emelia Fróði Karlsson -->
<!-- cSpell: ignore Hana Heesun Kyunghee Jiyu Revath Kumar -->
<!-- cSpell: ignore Hana Heesun Kyunghee Jiyu Revath Kumar Jaeyeol -->
<!-- cSpell: ignore Jiwon Kwon Hyeonseo Chanhaeng Hasang Hyunchae KeunHyeong -->
<!-- cSpell: ignore Jang Hanarae ByeongJun Subin -->
<!-- cSpell: ignore Wayst Konsole Ghostty Aplc -->
+1 −0
Original line number Diff line number Diff line
@@ -53,6 +53,7 @@
    "keypair",
    "langstr",
    "Lemmy",
    "lifecycles",
    "litepub",
    "logtape",
    "lume",
+90 −0
Original line number Diff line number Diff line
@@ -384,6 +384,96 @@ duplicate retry mechanisms and leverages the backend's optimized retry features.
[`@fedify/redis`]: https://github.com/fedify-dev/fedify/tree/main/packages/redis


Activity idempotency
--------------------

*This API is available since Fedify 1.9.0.*

In ActivityPub, the same activity might be delivered multiple times to your
inbox for various reasons, such as network failures, server restarts, or
federation protocol retries.  To prevent processing the same activity multiple
times, Fedify provides idempotency mechanisms that detect and skip duplicate
activities.

### Idempotency strategies

Fedify supports three built-in idempotency strategies:

`"per-inbox"`
:   Activities are deduplicated per inbox.  The same activity ID can be
    processed once per inbox, allowing the same activity to be delivered to
    multiple inboxes independently.  This follows standard ActivityPub behavior
    and will be the default in Fedify 2.0.

`"per-origin"`
:   Activities are deduplicated per receiving server's origin.  The same
    activity ID will be processed only once on each receiving server,
    but can be processed separately on different receiving servers.
    This was the default behavior in Fedify 1.x versions.

`"global"`
:   Activities are deduplicated globally across all inboxes and origins.
    The same activity ID will be processed only once, regardless of
    which inbox receives it or which server sent it.

You can configure the idempotency strategy using the
`~InboxListenerSetters.withIdempotency()` method:

~~~~ typescript twoslash
import { type Federation, Follow } from "@fedify/fedify";
const federation = null as unknown as Federation<void>;
// ---cut-before---
federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .withIdempotency("per-inbox")  // Standard ActivityPub behavior
  .on(Follow, async (ctx, follow) => {
    // Handle the follow activity
  });
~~~~

> [!WARNING]
> If you don't explicitly configure an idempotency strategy, Fedify currently
> uses `"per-origin"` as the default for backward compatibility.  However, this
> default will change to `"per-inbox"` in Fedify 2.0.  We recommend explicitly
> setting the strategy to avoid unexpected behavior changes.

### Custom idempotency strategy

If the built-in strategies don't meet your needs, you can implement a custom
idempotency strategy by providing a callback function.  The callback receives
the inbox context and the activity, and should return a unique cache key for
the activity, or `null` to skip idempotency checking for that activity:

~~~~ typescript twoslash
import { type Federation, Follow } from "@fedify/fedify";
const federation = null as unknown as Federation<void>;
// ---cut-before---
federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .withIdempotency(async (ctx, activity) => {
    // Skip idempotency for Follow activities
    if (activity instanceof Follow) return null;

    // Use per-inbox strategy for other activities
    const inboxId
      = ctx.recipient == null
      ? "shared"
      : `actor\n${ctx.recipient}`;
    return `${ctx.origin}\n${activity.id?.href}\n${inboxId}`;
  })
  .on(Follow, async (ctx, follow) => {
    // This Follow activity will not be deduplicated
  });
~~~~

### Idempotency cache

Processed activities are cached for 24 hours to detect duplicates.  The cache
uses the same [key–value store](./kv.md) that you provided to
the `createFederation()` function.  Cache keys are automatically namespaced to
avoid conflicts with other data.


Error handling
--------------

+12 −0
Original line number Diff line number Diff line
@@ -33,6 +33,8 @@ import type {
  Federation,
  FederationBuilder,
  FederationOptions,
  IdempotencyKeyCallback,
  IdempotencyStrategy,
  InboxListenerSetters,
  ObjectCallbackSetters,
  ParamsKeyPath,
@@ -102,6 +104,9 @@ export class FederationBuilderImpl<TContextData>
  inboxListeners?: InboxListenerSet<TContextData>;
  inboxErrorHandler?: InboxErrorHandler<TContextData>;
  sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>;
  idempotencyStrategy?:
    | IdempotencyStrategy
    | IdempotencyKeyCallback<TContextData>;
  collectionTypeIds: Record<
    string | symbol,
    ConstructorWithTypeId<Object>
@@ -178,6 +183,7 @@ export class FederationBuilderImpl<TContextData>
    f.inboxListeners = this.inboxListeners?.clone();
    f.inboxErrorHandler = this.inboxErrorHandler;
    f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher;
    f.idempotencyStrategy = this.idempotencyStrategy;
    return f;
  }

@@ -1202,6 +1208,12 @@ export class FederationBuilderImpl<TContextData>
        this.sharedInboxKeyDispatcher = dispatcher;
        return setters;
      },
      withIdempotency: (
        strategy: IdempotencyStrategy | IdempotencyKeyCallback<TContextData>,
      ): InboxListenerSetters<TContextData> => {
        this.idempotencyStrategy = strategy;
        return setters;
      },
    };
    return setters;
  }
+69 −1
Original line number Diff line number Diff line
@@ -30,7 +30,7 @@ import type {
  SharedInboxKeyDispatcher,
  WebFingerLinksDispatcher,
} from "./callback.ts";
import type { Context, RequestContext } from "./context.ts";
import type { Context, InboxContext, RequestContext } from "./context.ts";
import type { KvStore } from "./kv.ts";
import type {
  FederationKvPrefixes,
@@ -952,6 +952,41 @@ export interface CollectionCallbackSetters<
  ): CollectionCallbackSetters<TContext, TContextData, TFilter>;
}

/**
 * The strategy for handling activity idempotency in inbox processing.
 *
 *  -  `"global"`: Activities are deduplicated globally across all inboxes and
 *     origins.  The same activity ID will be processed only once, regardless
 *     of which inbox receives it or which server sent it.
 *
 *  -  `"per-origin"`: Activities are deduplicated per receiving server's origin.
 *     The same activity ID will be processed only once on each receiving server,
 *     but can be processed separately on different receiving servers. This was
 *     the default behavior in Fedify 1.x versions.
 *
 *  -  `"per-inbox"`: Activities are deduplicated per inbox. The same activity
 *     ID can be processed once per inbox, allowing the same activity to be
 *     delivered to multiple inboxes independently.  This follows standard
 *     ActivityPub behavior and will be the default in Fedify 2.0.
 *
 * @since 1.9.0
 */
export type IdempotencyStrategy = "global" | "per-origin" | "per-inbox";

/**
 * A callback to generate a custom idempotency key for an activity.
 * Returns the cache key to use, or null to skip idempotency checking.
 * @template TContextData The context data to pass to the {@link InboxContext}.
 * @param ctx The inbox context.
 * @param activity The activity being processed.
 * @returns The idempotency key to use for caching, or null to skip caching.
 * @since 1.9.0
 */
export type IdempotencyKeyCallback<TContextData> = (
  ctx: InboxContext<TContextData>,
  activity: Activity,
) => string | null | Promise<string | null>;

/**
 * Registry for inbox listeners for different activity types.
 */
@@ -992,6 +1027,39 @@ export interface InboxListenerSetters<TContextData> {
  setSharedKeyDispatcher(
    dispatcher: SharedInboxKeyDispatcher<TContextData>,
  ): InboxListenerSetters<TContextData>;

  /**
   * Configures the strategy for handling activity idempotency in inbox processing.
   *
   * @example
   * Use per-inbox strategy (standard ActivityPub behavior):
   * ```
   * federation
   *   .setInboxListeners("/users/{identifier}/inbox", "/inbox")
   *   .withIdempotency("per-inbox");
   * ```
   *
   * Use custom strategy:
   * ```
   * federation
   *   .setInboxListeners("/users/{identifier}/inbox", "/inbox")
   *   .withIdempotency((ctx, activity) => {
   *     // Return null to skip idempotency
   *     return `${ctx.origin}:${activity.id?.href}:${ctx.recipient}`;
   *   });
   * ```
   *
   * @param strategy The idempotency strategy to use. Can be:
   *   - `"global"`: Activities are deduplicated across all inboxes and origins
   *   - `"per-origin"`: Activities are deduplicated per inbox origin
   *   - `"per-inbox"`: Activities are deduplicated per inbox
   *   - A custom callback function that returns the cache key to use
   * @returns The setters object so that settings can be chained.
   * @since 1.9.0
   */
  withIdempotency(
    strategy: IdempotencyStrategy | IdempotencyKeyCallback<TContextData>,
  ): InboxListenerSetters<TContextData>;
}

/**
Loading