Skip to content

Commit cbc8aca

Browse files
dahliaclaude
andcommitted
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 #441 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 45b856c commit cbc8aca

10 files changed

Lines changed: 577 additions & 13 deletions

File tree

CHANGES.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ To be released.
3131
- Internal trust tracking system maintains security context throughout
3232
object lifecycles (construction, cloning, and property access).
3333

34+
- Added `withIdempotency()` method to configure activity idempotency
35+
strategies for inbox processing. This addresses issue [#441] where
36+
activities with the same ID sent to different inboxes were incorrectly
37+
deduplicated globally instead of per-inbox. [[#441]]
38+
39+
- Added `IdempotencyStrategy` type.
40+
- Added `IdempotencyKeyCallback` type.
41+
- Added `InboxListenerSetters.withIdempotency()` method.
42+
- By default, `"per-origin"` strategy is used for backward compatibility.
43+
This will change to `"per-inbox"` in Fedify 2.0. We recommend
44+
explicitly setting the strategy to avoid unexpected behavior changes.
45+
3446
- Fixed handling of ActivityPub objects containing relative URLs. The
3547
Activity Vocabulary classes now automatically resolve relative URLs by
3648
inferring the base URL from the object's `@id` or document URL, eliminating
@@ -111,6 +123,7 @@ To be released.
111123
[#429]: https://github.com/fedify-dev/fedify/issues/429
112124
[#431]: https://github.com/fedify-dev/fedify/pull/431
113125
[#440]: https://github.com/fedify-dev/fedify/issues/440
126+
[#441]: https://github.com/fedify-dev/fedify/issues/441
114127
[#443]: https://github.com/fedify-dev/fedify/pull/443
115128

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

257-
Released on Steptember 17, 2025.
270+
Released on September 17, 2025.
258271

259272
### @fedify/fedify
260273

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

52235236
<!-- cSpell: ignore Dogeon Fabien Wressell Emelia Fróði Karlsson -->
5224-
<!-- cSpell: ignore Hana Heesun Kyunghee Jiyu Revath Kumar -->
5237+
<!-- cSpell: ignore Hana Heesun Kyunghee Jiyu Revath Kumar Jaeyeol -->
5238+
<!-- cSpell: ignore Jiwon Kwon Hyeonseo Chanhaeng Hasang Hyunchae KeunHyeong -->
5239+
<!-- cSpell: ignore Jang Hanarae ByeongJun Subin -->
5240+
<!-- cSpell: ignore Wayst Konsole Ghostty Aplc -->

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"keypair",
5454
"langstr",
5555
"Lemmy",
56+
"lifecycles",
5657
"litepub",
5758
"logtape",
5859
"lume",

docs/manual/inbox.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,96 @@ duplicate retry mechanisms and leverages the backend's optimized retry features.
384384
[`@fedify/redis`]: https://github.com/fedify-dev/fedify/tree/main/packages/redis
385385

386386

387+
Activity idempotency
388+
--------------------
389+
390+
*This API is available since Fedify 1.9.0.*
391+
392+
In ActivityPub, the same activity might be delivered multiple times to your
393+
inbox for various reasons, such as network failures, server restarts, or
394+
federation protocol retries. To prevent processing the same activity multiple
395+
times, Fedify provides idempotency mechanisms that detect and skip duplicate
396+
activities.
397+
398+
### Idempotency strategies
399+
400+
Fedify supports three built-in idempotency strategies:
401+
402+
`"per-inbox"`
403+
: Activities are deduplicated per inbox. The same activity ID can be
404+
processed once per inbox, allowing the same activity to be delivered to
405+
multiple inboxes independently. This follows standard ActivityPub behavior
406+
and will be the default in Fedify 2.0.
407+
408+
`"per-origin"`
409+
: Activities are deduplicated per receiving server's origin. The same
410+
activity ID will be processed only once on each receiving server,
411+
but can be processed separately on different receiving servers.
412+
This was the default behavior in Fedify 1.x versions.
413+
414+
`"global"`
415+
: Activities are deduplicated globally across all inboxes and origins.
416+
The same activity ID will be processed only once, regardless of
417+
which inbox receives it or which server sent it.
418+
419+
You can configure the idempotency strategy using the
420+
`~InboxListenerSetters.withIdempotency()` method:
421+
422+
~~~~ typescript twoslash
423+
import { type Federation, Follow } from "@fedify/fedify";
424+
const federation = null as unknown as Federation<void>;
425+
// ---cut-before---
426+
federation
427+
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
428+
.withIdempotency("per-inbox") // Standard ActivityPub behavior
429+
.on(Follow, async (ctx, follow) => {
430+
// Handle the follow activity
431+
});
432+
~~~~
433+
434+
> [!WARNING]
435+
> If you don't explicitly configure an idempotency strategy, Fedify currently
436+
> uses `"per-origin"` as the default for backward compatibility. However, this
437+
> default will change to `"per-inbox"` in Fedify 2.0. We recommend explicitly
438+
> setting the strategy to avoid unexpected behavior changes.
439+
440+
### Custom idempotency strategy
441+
442+
If the built-in strategies don't meet your needs, you can implement a custom
443+
idempotency strategy by providing a callback function. The callback receives
444+
the inbox context and the activity, and should return a unique cache key for
445+
the activity, or `null` to skip idempotency checking for that activity:
446+
447+
~~~~ typescript twoslash
448+
import { type Federation, Follow } from "@fedify/fedify";
449+
const federation = null as unknown as Federation<void>;
450+
// ---cut-before---
451+
federation
452+
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
453+
.withIdempotency(async (ctx, activity) => {
454+
// Skip idempotency for Follow activities
455+
if (activity instanceof Follow) return null;
456+
457+
// Use per-inbox strategy for other activities
458+
const inboxId
459+
= ctx.recipient == null
460+
? "shared"
461+
: `actor\n${ctx.recipient}`;
462+
return `${ctx.origin}\n${activity.id?.href}\n${inboxId}`;
463+
})
464+
.on(Follow, async (ctx, follow) => {
465+
// This Follow activity will not be deduplicated
466+
});
467+
~~~~
468+
469+
### Idempotency cache
470+
471+
Processed activities are cached for 24 hours to detect duplicates. The cache
472+
uses the same [key–value store](./kv.md) that you provided to
473+
the `createFederation()` function. Cache keys are automatically namespaced to
474+
avoid conflicts with other data.
475+
476+
387477
Error handling
388478
--------------
389479

packages/fedify/src/federation/builder.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import type {
3333
Federation,
3434
FederationBuilder,
3535
FederationOptions,
36+
IdempotencyKeyCallback,
37+
IdempotencyStrategy,
3638
InboxListenerSetters,
3739
ObjectCallbackSetters,
3840
ParamsKeyPath,
@@ -102,6 +104,9 @@ export class FederationBuilderImpl<TContextData>
102104
inboxListeners?: InboxListenerSet<TContextData>;
103105
inboxErrorHandler?: InboxErrorHandler<TContextData>;
104106
sharedInboxKeyDispatcher?: SharedInboxKeyDispatcher<TContextData>;
107+
idempotencyStrategy?:
108+
| IdempotencyStrategy
109+
| IdempotencyKeyCallback<TContextData>;
105110
collectionTypeIds: Record<
106111
string | symbol,
107112
ConstructorWithTypeId<Object>
@@ -178,6 +183,7 @@ export class FederationBuilderImpl<TContextData>
178183
f.inboxListeners = this.inboxListeners?.clone();
179184
f.inboxErrorHandler = this.inboxErrorHandler;
180185
f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher;
186+
f.idempotencyStrategy = this.idempotencyStrategy;
181187
return f;
182188
}
183189

@@ -1202,6 +1208,12 @@ export class FederationBuilderImpl<TContextData>
12021208
this.sharedInboxKeyDispatcher = dispatcher;
12031209
return setters;
12041210
},
1211+
withIdempotency: (
1212+
strategy: IdempotencyStrategy | IdempotencyKeyCallback<TContextData>,
1213+
): InboxListenerSetters<TContextData> => {
1214+
this.idempotencyStrategy = strategy;
1215+
return setters;
1216+
},
12051217
};
12061218
return setters;
12071219
}

packages/fedify/src/federation/federation.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import type {
3030
SharedInboxKeyDispatcher,
3131
WebFingerLinksDispatcher,
3232
} from "./callback.ts";
33-
import type { Context, RequestContext } from "./context.ts";
33+
import type { Context, InboxContext, RequestContext } from "./context.ts";
3434
import type { KvStore } from "./kv.ts";
3535
import type {
3636
FederationKvPrefixes,
@@ -952,6 +952,41 @@ export interface CollectionCallbackSetters<
952952
): CollectionCallbackSetters<TContext, TContextData, TFilter>;
953953
}
954954

955+
/**
956+
* The strategy for handling activity idempotency in inbox processing.
957+
*
958+
* - `"global"`: Activities are deduplicated globally across all inboxes and
959+
* origins. The same activity ID will be processed only once, regardless
960+
* of which inbox receives it or which server sent it.
961+
*
962+
* - `"per-origin"`: Activities are deduplicated per receiving server's origin.
963+
* The same activity ID will be processed only once on each receiving server,
964+
* but can be processed separately on different receiving servers. This was
965+
* the default behavior in Fedify 1.x versions.
966+
*
967+
* - `"per-inbox"`: Activities are deduplicated per inbox. The same activity
968+
* ID can be processed once per inbox, allowing the same activity to be
969+
* delivered to multiple inboxes independently. This follows standard
970+
* ActivityPub behavior and will be the default in Fedify 2.0.
971+
*
972+
* @since 1.9.0
973+
*/
974+
export type IdempotencyStrategy = "global" | "per-origin" | "per-inbox";
975+
976+
/**
977+
* A callback to generate a custom idempotency key for an activity.
978+
* Returns the cache key to use, or null to skip idempotency checking.
979+
* @template TContextData The context data to pass to the {@link InboxContext}.
980+
* @param ctx The inbox context.
981+
* @param activity The activity being processed.
982+
* @returns The idempotency key to use for caching, or null to skip caching.
983+
* @since 1.9.0
984+
*/
985+
export type IdempotencyKeyCallback<TContextData> = (
986+
ctx: InboxContext<TContextData>,
987+
activity: Activity,
988+
) => string | null | Promise<string | null>;
989+
955990
/**
956991
* Registry for inbox listeners for different activity types.
957992
*/
@@ -992,6 +1027,39 @@ export interface InboxListenerSetters<TContextData> {
9921027
setSharedKeyDispatcher(
9931028
dispatcher: SharedInboxKeyDispatcher<TContextData>,
9941029
): InboxListenerSetters<TContextData>;
1030+
1031+
/**
1032+
* Configures the strategy for handling activity idempotency in inbox processing.
1033+
*
1034+
* @example
1035+
* Use per-inbox strategy (standard ActivityPub behavior):
1036+
* ```
1037+
* federation
1038+
* .setInboxListeners("/users/{identifier}/inbox", "/inbox")
1039+
* .withIdempotency("per-inbox");
1040+
* ```
1041+
*
1042+
* Use custom strategy:
1043+
* ```
1044+
* federation
1045+
* .setInboxListeners("/users/{identifier}/inbox", "/inbox")
1046+
* .withIdempotency((ctx, activity) => {
1047+
* // Return null to skip idempotency
1048+
* return `${ctx.origin}:${activity.id?.href}:${ctx.recipient}`;
1049+
* });
1050+
* ```
1051+
*
1052+
* @param strategy The idempotency strategy to use. Can be:
1053+
* - `"global"`: Activities are deduplicated across all inboxes and origins
1054+
* - `"per-origin"`: Activities are deduplicated per inbox origin
1055+
* - `"per-inbox"`: Activities are deduplicated per inbox
1056+
* - A custom callback function that returns the cache key to use
1057+
* @returns The setters object so that settings can be chained.
1058+
* @since 1.9.0
1059+
*/
1060+
withIdempotency(
1061+
strategy: IdempotencyStrategy | IdempotencyKeyCallback<TContextData>,
1062+
): InboxListenerSetters<TContextData>;
9951063
}
9961064

9971065
/**

packages/fedify/src/federation/handler.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ import type {
3939
} from "./callback.ts";
4040
import type { PageItems } from "./collection.ts";
4141
import type { Context, InboxContext, RequestContext } from "./context.ts";
42-
import type { ConstructorWithTypeId } from "./federation.ts";
42+
import type {
43+
ConstructorWithTypeId,
44+
IdempotencyKeyCallback,
45+
IdempotencyStrategy,
46+
} from "./federation.ts";
4347
import { type InboxListenerSet, routeActivity } from "./inbox.ts";
4448
import { KvKeyCache } from "./keycache.ts";
4549
import type { KvKey, KvStore } from "./kv.ts";
@@ -551,6 +555,9 @@ export interface InboxHandlerParameters<TContextData> {
551555
onNotFound(request: Request): Response | Promise<Response>;
552556
signatureTimeWindow: Temporal.Duration | Temporal.DurationLike | false;
553557
skipSignatureVerification: boolean;
558+
idempotencyStrategy?:
559+
| IdempotencyStrategy
560+
| IdempotencyKeyCallback<TContextData>;
554561
tracerProvider?: TracerProvider;
555562
}
556563

@@ -599,7 +606,10 @@ export async function handleInbox<TContextData>(
599606
*/
600607
async function handleInboxInternal<TContextData>(
601608
request: Request,
602-
{
609+
parameters: InboxHandlerParameters<TContextData>,
610+
span: Span,
611+
): Promise<Response> {
612+
const {
603613
recipient,
604614
context: ctx,
605615
inboxContextFactory,
@@ -613,9 +623,7 @@ async function handleInboxInternal<TContextData>(
613623
signatureTimeWindow,
614624
skipSignatureVerification,
615625
tracerProvider,
616-
}: InboxHandlerParameters<TContextData>,
617-
span: Span,
618-
): Promise<Response> {
626+
} = parameters;
619627
const logger = getLogger(["fedify", "federation", "inbox"]);
620628
if (actorDispatcher == null) {
621629
logger.error("Actor dispatcher is not set.", { recipient });
@@ -822,6 +830,7 @@ async function handleInboxInternal<TContextData>(
822830
queue,
823831
span,
824832
tracerProvider,
833+
idempotencyStrategy: parameters.idempotencyStrategy,
825834
});
826835
if (routeResult === "alreadyProcessed") {
827836
return new Response(

0 commit comments

Comments
 (0)