@fedify/fedify 2.3.0-dev.1189 → 2.3.0-dev.1190

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/{builder-Dc6s3gPe.mjs → builder-BzgNpXoY.mjs} +2 -2
  2. package/dist/circuit-breaker-CSWsyoef.mjs +337 -0
  3. package/dist/compat/mod.d.cts +1 -1
  4. package/dist/compat/mod.d.ts +1 -1
  5. package/dist/compat/transformers.test.mjs +1 -1
  6. package/dist/{context-CRXCkTM6.d.cts → context-DMHK7jqX.d.cts} +224 -3
  7. package/dist/{context-MgCh7YGu.d.ts → context-K9cg8oGx.d.ts} +224 -3
  8. package/dist/{deno-BomxIkHS.mjs → deno-CoAwVm1I.mjs} +1 -1
  9. package/dist/{docloader-CzS6F5sZ.mjs → docloader-hPqZT20O.mjs} +2 -2
  10. package/dist/federation/builder.test.mjs +1 -1
  11. package/dist/federation/circuit-breaker.test.d.mts +2 -0
  12. package/dist/federation/circuit-breaker.test.mjs +446 -0
  13. package/dist/federation/collection.test.mjs +1 -1
  14. package/dist/federation/handler.test.mjs +3 -3
  15. package/dist/federation/idempotency.test.mjs +2 -2
  16. package/dist/federation/keycache.test.mjs +1 -1
  17. package/dist/federation/metrics.test.mjs +16 -1
  18. package/dist/federation/middleware.test.mjs +817 -6
  19. package/dist/federation/mod.cjs +4 -1
  20. package/dist/federation/mod.d.cts +3 -3
  21. package/dist/federation/mod.d.ts +3 -3
  22. package/dist/federation/mod.js +2 -2
  23. package/dist/federation/negotiation.test.mjs +1 -1
  24. package/dist/federation/retry.test.mjs +1 -1
  25. package/dist/federation/send.test.mjs +43 -10
  26. package/dist/federation/temporal.test.mjs +1 -1
  27. package/dist/federation/webfinger.test.mjs +1 -1
  28. package/dist/{getMachineId-bsd-BY01PL1n.mjs → getMachineId-bsd-Bn0le7-J.mjs} +1 -1
  29. package/dist/{getMachineId-darwin-Dr1gkBkp.mjs → getMachineId-darwin-CVjKuDgj.mjs} +1 -1
  30. package/dist/{getMachineId-win-QEYwcJiy.mjs → getMachineId-win-c5zxTSS1.mjs} +1 -1
  31. package/dist/{http-DnJyL_6c.cjs → http-BAarxBe5.cjs} +30 -5
  32. package/dist/{http-DtWN_XvX.mjs → http-CSwCAQ-H.mjs} +3 -3
  33. package/dist/{http-B-psRIq6.js → http-Dq_qElWc.js} +25 -6
  34. package/dist/{key-CT2NnJuR.mjs → key-DYK_T_PD.mjs} +2 -2
  35. package/dist/{kv-cache-DKhLDCH8.js → kv-cache-BhPocHdd.js} +1 -1
  36. package/dist/{kv-cache-Bf8AoV6C.mjs → kv-cache-CFzIDCMJ.mjs} +1 -1
  37. package/dist/{kv-cache-CVre456Y.cjs → kv-cache-Ds1kjvnu.cjs} +1 -1
  38. package/dist/{ld-DCyQasTE.mjs → ld-BdcT_irA.mjs} +3 -3
  39. package/dist/{metrics-xgr0P4hO.mjs → metrics-Ci97wkob.mjs} +25 -6
  40. package/dist/{middleware-DK0thDHX.mjs → middleware-BUGT2LmO.mjs} +279 -40
  41. package/dist/{middleware-BgbdoV61.js → middleware-C-C_I_wJ.js} +615 -32
  42. package/dist/{middleware-DIJ_6KFI.cjs → middleware-ddMAHsyF.cjs} +632 -31
  43. package/dist/{middleware-sgx08IEk.mjs → middleware-hWs3qtrr.mjs} +1 -1
  44. package/dist/{mod-CpQHB3Ys.d.ts → mod-CfOFqS0w.d.ts} +1 -1
  45. package/dist/{mod-C7HOzGqH.d.cts → mod-YLnSsEHY.d.cts} +1 -1
  46. package/dist/mod.cjs +7 -4
  47. package/dist/mod.d.cts +4 -4
  48. package/dist/mod.d.ts +4 -4
  49. package/dist/mod.js +5 -5
  50. package/dist/nodeinfo/handler.test.mjs +1 -1
  51. package/dist/{owner-BIU_Sl7y.mjs → owner-B8ePZh4q.mjs} +2 -2
  52. package/dist/{proof-B9xbksrX.cjs → proof-CXdtqYKw.cjs} +1 -1
  53. package/dist/{proof-DDs7BRl7.mjs → proof-CzqluPMh.mjs} +3 -3
  54. package/dist/{proof-B5defvTr.js → proof-Dq_RyTjd.js} +1 -1
  55. package/dist/{send-BuxDCpxz.mjs → send-NzJqiStx.mjs} +21 -7
  56. package/dist/sig/http.test.mjs +2 -2
  57. package/dist/sig/key.test.mjs +1 -1
  58. package/dist/sig/ld.test.mjs +2 -2
  59. package/dist/sig/mod.cjs +2 -2
  60. package/dist/sig/mod.js +2 -2
  61. package/dist/sig/owner.test.mjs +1 -1
  62. package/dist/sig/proof.test.mjs +1 -1
  63. package/dist/{temporal-DHgeMWiP.mjs → temporal-CnhE0LLn.mjs} +1 -1
  64. package/dist/testing/mod.d.mts +36 -2
  65. package/dist/utils/docloader.test.mjs +2 -2
  66. package/dist/utils/kv-cache.test.mjs +1 -1
  67. package/dist/utils/mod.cjs +1 -1
  68. package/dist/utils/mod.js +1 -1
  69. package/package.json +7 -7
  70. /package/dist/{collection-CA3V5zyK.mjs → collection-Cc3DVAhE.mjs} +0 -0
  71. /package/dist/{execAsync-Dxb7rNf3.mjs → execAsync-Dmet7-28.mjs} +0 -0
  72. /package/dist/{getMachineId-linux-Bbhofx-s.mjs → getMachineId-linux-DbG4BXa-.mjs} +0 -0
  73. /package/dist/{getMachineId-unsupported-dIOte2Ct.mjs → getMachineId-unsupported-lC8T9hPE.mjs} +0 -0
  74. /package/dist/{keycache-BYMd8q7F.mjs → keycache-BeU0LCII.mjs} +0 -0
  75. /package/dist/{negotiation-CDW-_gUU.mjs → negotiation-DDstyBvc.mjs} +0 -0
  76. /package/dist/{retry-_VvV0h9f.mjs → retry-CXg_MBI-.mjs} +0 -0
@@ -10,14 +10,15 @@ import { t as assertNotEquals } from "../assert_not_equals-DkVK8oqV.mjs";
10
10
  import { t as assertStrictEquals } from "../assert_strict_equals-XEgZAlrj.mjs";
11
11
  import { t as assert } from "../assert-OguE97r2.mjs";
12
12
  import { t as esm_default } from "../esm-BQRw925N.mjs";
13
- import { l as verifyRequest, s as signRequest } from "../http-DtWN_XvX.mjs";
13
+ import { l as verifyRequest, s as signRequest } from "../http-CSwCAQ-H.mjs";
14
14
  import { a as rsaPrivateKey3, c as rsaPublicKey3, i as rsaPrivateKey2, n as ed25519PrivateKey, r as ed25519PublicKey, s as rsaPublicKey2, t as ed25519Multikey } from "../keys-C3kae-6B.mjs";
15
- import { t as getAuthenticatedDocumentLoader } from "../docloader-CzS6F5sZ.mjs";
16
- import { a as compactJsonLd, h as verifyJsonLd, p as signJsonLd, s as detachSignature } from "../ld-DCyQasTE.mjs";
17
- import { t as doesActorOwnKey } from "../owner-BIU_Sl7y.mjs";
18
- import { i as verifyObject, r as signObject } from "../proof-DDs7BRl7.mjs";
15
+ import { t as getAuthenticatedDocumentLoader } from "../docloader-hPqZT20O.mjs";
16
+ import { a as compactJsonLd, h as verifyJsonLd, p as signJsonLd, s as detachSignature } from "../ld-BdcT_irA.mjs";
17
+ import { t as doesActorOwnKey } from "../owner-B8ePZh4q.mjs";
18
+ import { i as verifyObject, r as signObject } from "../proof-CzqluPMh.mjs";
19
19
  import { t as MemoryKvStore } from "../kv-x2IvBUyq.mjs";
20
- import { i as KvSpecDeterminer, n as FederationImpl, o as createFederation, r as InboxContextImpl, t as ContextImpl } from "../middleware-DK0thDHX.mjs";
20
+ import { i as KvSpecDeterminer, n as FederationImpl, o as createFederation, r as InboxContextImpl, t as ContextImpl } from "../middleware-BUGT2LmO.mjs";
21
+ import { t as CircuitBreaker } from "../circuit-breaker-CSWsyoef.mjs";
21
22
  import { configure, reset } from "@logtape/logtape";
22
23
  import { RouterError } from "@fedify/uri-template";
23
24
  import * as vocab from "@fedify/vocab";
@@ -4273,6 +4274,816 @@ test("FederationImpl.processQueuedTask() permanent failure", async (t) => {
4273
4274
  });
4274
4275
  esm_default.hardReset();
4275
4276
  });
4277
+ test("FederationImpl.processQueuedTask() circuit breaker", async (t) => {
4278
+ esm_default.spyGlobal();
4279
+ function setup(options, federationOptions = {}, queueOptions = {}) {
4280
+ const kv = new MemoryKvStore();
4281
+ const queued = [];
4282
+ const federation = new FederationImpl({
4283
+ kv,
4284
+ queue: {
4285
+ nativeRetrial: queueOptions.nativeRetrial,
4286
+ enqueue(message, options) {
4287
+ queued.push({
4288
+ message,
4289
+ options
4290
+ });
4291
+ return Promise.resolve();
4292
+ },
4293
+ listen(_handler, _options) {
4294
+ return Promise.resolve();
4295
+ }
4296
+ },
4297
+ circuitBreaker: options,
4298
+ ...federationOptions
4299
+ });
4300
+ federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
4301
+ return {
4302
+ federation,
4303
+ kv,
4304
+ queued
4305
+ };
4306
+ }
4307
+ function createOutboxMessage(inbox, overrides = {}) {
4308
+ return {
4309
+ type: "outbox",
4310
+ id: crypto.randomUUID(),
4311
+ baseUrl: "https://example.com",
4312
+ keys: [],
4313
+ activity: {
4314
+ "@context": "https://www.w3.org/ns/activitystreams",
4315
+ type: "Create",
4316
+ id: "https://example.com/activity/circuit",
4317
+ actor: "https://example.com/users/alice",
4318
+ object: {
4319
+ type: "Note",
4320
+ content: "test"
4321
+ }
4322
+ },
4323
+ activityId: "https://example.com/activity/circuit",
4324
+ activityType: "https://www.w3.org/ns/activitystreams#Create",
4325
+ inbox,
4326
+ sharedInbox: false,
4327
+ actorIds: ["https://breaker.example/users/bob"],
4328
+ started: (/* @__PURE__ */ new Date()).toISOString(),
4329
+ attempt: 0,
4330
+ headers: {},
4331
+ traceContext: {},
4332
+ ...overrides
4333
+ };
4334
+ }
4335
+ await t.step("is not created without an outbox queue", () => {
4336
+ assertEquals(new FederationImpl({ kv: new MemoryKvStore() }).circuitBreaker, void 0);
4337
+ });
4338
+ await t.step("5xx opens circuit and holds the failed message", async () => {
4339
+ esm_default.hardReset();
4340
+ esm_default.spyGlobal();
4341
+ esm_default.post("https://breaker.example/inbox", {
4342
+ status: 500,
4343
+ body: "server error"
4344
+ });
4345
+ const { federation, queued, kv } = setup({
4346
+ failureThreshold: 1,
4347
+ failureWindow: { minutes: 10 },
4348
+ recoveryDelay: { minutes: 30 }
4349
+ });
4350
+ const orderingKey = "https://example.com/object/breaker";
4351
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://breaker.example/inbox", { orderingKey }));
4352
+ assertEquals(queued.length, 1);
4353
+ const held = queued[0].message;
4354
+ assertEquals(held.attempt, 0);
4355
+ assertEquals(held.orderingKey, orderingKey);
4356
+ assertEquals(held.circuitHeld, true);
4357
+ assertExists(held.circuitHeldSince);
4358
+ assertEquals(queued[0].options?.orderingKey, orderingKey);
4359
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ minutes: 30 }));
4360
+ const state = await kv.get([
4361
+ "_fedify",
4362
+ "circuit",
4363
+ "breaker.example"
4364
+ ]);
4365
+ assertEquals(state?.state, "open");
4366
+ assertEquals(Array.isArray(state?.failures), true);
4367
+ assertEquals((state?.failures).length, 1);
4368
+ assertExists(state?.opened);
4369
+ });
4370
+ await t.step("open circuit requeues without sending", async () => {
4371
+ esm_default.hardReset();
4372
+ esm_default.spyGlobal();
4373
+ let requests = 0;
4374
+ esm_default.post("https://open.example/inbox", () => {
4375
+ requests++;
4376
+ return {
4377
+ status: 500,
4378
+ body: "server error"
4379
+ };
4380
+ });
4381
+ const { federation, queued } = setup({
4382
+ failureThreshold: 1,
4383
+ recoveryDelay: { hours: 1 }
4384
+ });
4385
+ const orderingKey = "https://example.com/object/open";
4386
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://open.example/inbox", { orderingKey }));
4387
+ const held = queued[0].message;
4388
+ queued.length = 0;
4389
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://open.example/inbox", {
4390
+ circuitHeld: true,
4391
+ circuitHeldSince: held.circuitHeldSince,
4392
+ orderingKey
4393
+ }));
4394
+ assertEquals(requests, 1);
4395
+ assertEquals(queued.length, 1);
4396
+ const requeued = queued[0].message;
4397
+ assertEquals(requeued.attempt, 0);
4398
+ assertEquals(requeued.orderingKey, orderingKey);
4399
+ assertEquals(requeued.circuitHeld, true);
4400
+ assertEquals(requeued.circuitHeldSince, held.circuitHeldSince);
4401
+ assertEquals(queued[0].options?.orderingKey, orderingKey);
4402
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ hours: 1 }));
4403
+ });
4404
+ await t.step("circuit keys include non-default ports", async () => {
4405
+ esm_default.hardReset();
4406
+ esm_default.spyGlobal();
4407
+ let defaultPortRequests = 0;
4408
+ esm_default.post("https://ports.example:8443/inbox", {
4409
+ status: 500,
4410
+ body: "server error"
4411
+ });
4412
+ esm_default.post("https://ports.example/inbox", () => {
4413
+ defaultPortRequests++;
4414
+ return {
4415
+ status: 202,
4416
+ body: ""
4417
+ };
4418
+ });
4419
+ const { federation, queued, kv } = setup({
4420
+ failureThreshold: 1,
4421
+ recoveryDelay: { hours: 1 }
4422
+ });
4423
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://ports.example:8443/inbox"));
4424
+ assertEquals((await kv.get([
4425
+ "_fedify",
4426
+ "circuit",
4427
+ "ports.example:8443"
4428
+ ]))?.state, "open");
4429
+ assertEquals(await kv.get([
4430
+ "_fedify",
4431
+ "circuit",
4432
+ "ports.example"
4433
+ ]), void 0);
4434
+ queued.length = 0;
4435
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://ports.example/inbox"));
4436
+ assertEquals(defaultPortRequests, 1);
4437
+ assertEquals(queued, []);
4438
+ });
4439
+ await t.step("post-send circuit errors do not retry delivery", async () => {
4440
+ esm_default.hardReset();
4441
+ esm_default.spyGlobal();
4442
+ esm_default.post("https://success-bookkeeping.example/inbox", {
4443
+ status: 202,
4444
+ body: ""
4445
+ });
4446
+ const { federation, queued, kv } = setup({ failureThreshold: 1 });
4447
+ await kv.set([
4448
+ "_fedify",
4449
+ "circuit",
4450
+ "success-bookkeeping.example"
4451
+ ], {
4452
+ state: "closed",
4453
+ failures: []
4454
+ });
4455
+ kv.cas = () => Promise.resolve(false);
4456
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://success-bookkeeping.example/inbox"));
4457
+ assertEquals(queued, []);
4458
+ });
4459
+ await t.step("pre-send circuit errors do not block delivery", async () => {
4460
+ esm_default.hardReset();
4461
+ esm_default.spyGlobal();
4462
+ let requests = 0;
4463
+ esm_default.post("https://presend-bookkeeping.example/inbox", () => {
4464
+ requests++;
4465
+ return {
4466
+ status: 202,
4467
+ body: ""
4468
+ };
4469
+ });
4470
+ const { federation, queued, kv } = setup({ failureThreshold: 1 });
4471
+ kv.get = () => Promise.reject(/* @__PURE__ */ new Error("kv get failed"));
4472
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://presend-bookkeeping.example/inbox"));
4473
+ assertEquals(requests, 1);
4474
+ assertEquals(queued, []);
4475
+ });
4476
+ await t.step("circuit failure errors fall back to retry", async () => {
4477
+ esm_default.hardReset();
4478
+ esm_default.spyGlobal();
4479
+ esm_default.post("https://failure-bookkeeping.example/inbox", {
4480
+ status: 500,
4481
+ body: "server error"
4482
+ });
4483
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4484
+ kv.cas = () => Promise.resolve(false);
4485
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://failure-bookkeeping.example/inbox"));
4486
+ assertEquals(queued.length, 1);
4487
+ const retry = queued[0].message;
4488
+ assertEquals(retry.attempt, 1);
4489
+ assertEquals(retry.circuitHeld, void 0);
4490
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 3 }));
4491
+ });
4492
+ await t.step("local delivery errors do not open circuit", async () => {
4493
+ esm_default.hardReset();
4494
+ esm_default.spyGlobal();
4495
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4496
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://local-error.example/inbox", { headers: { "Invalid Header": "x" } }));
4497
+ assertEquals(queued.length, 1);
4498
+ const retry = queued[0].message;
4499
+ assertEquals(retry.attempt, 1);
4500
+ assertEquals(retry.circuitHeld, void 0);
4501
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 3 }));
4502
+ assertEquals(await kv.get([
4503
+ "_fedify",
4504
+ "circuit",
4505
+ "local-error.example"
4506
+ ]), void 0);
4507
+ });
4508
+ await t.step("calendar retry delays are enqueued", async () => {
4509
+ esm_default.hardReset();
4510
+ esm_default.spyGlobal();
4511
+ esm_default.post("https://calendar-delay.example/inbox", {
4512
+ status: 500,
4513
+ body: "server error"
4514
+ });
4515
+ const { federation, queued } = setup({ failureThreshold: 5 }, { outboxRetryPolicy: () => Temporal.Duration.from({ days: 1 }) });
4516
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://calendar-delay.example/inbox"));
4517
+ assertEquals(queued.length, 1);
4518
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ days: 1 }));
4519
+ });
4520
+ await t.step("negative calendar retry delays are clamped", async () => {
4521
+ esm_default.hardReset();
4522
+ esm_default.spyGlobal();
4523
+ esm_default.post("https://negative-calendar-delay.example/inbox", {
4524
+ status: 500,
4525
+ body: "server error"
4526
+ });
4527
+ const { federation, queued } = setup({ failureThreshold: 5 }, { outboxRetryPolicy: () => Temporal.Duration.from({ days: -1 }) });
4528
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://negative-calendar-delay.example/inbox"));
4529
+ assertEquals(queued.length, 1);
4530
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 0 }));
4531
+ });
4532
+ await t.step("circuit hold respects retry give-up", async () => {
4533
+ esm_default.hardReset();
4534
+ esm_default.spyGlobal();
4535
+ esm_default.post("https://hold-give-up.example/inbox", {
4536
+ status: 500,
4537
+ body: "server error"
4538
+ });
4539
+ const { federation, queued, kv } = setup({
4540
+ failureThreshold: 1,
4541
+ recoveryDelay: { minutes: 30 }
4542
+ }, { outboxRetryPolicy: () => null });
4543
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://hold-give-up.example/inbox"));
4544
+ assertEquals(queued, []);
4545
+ assertEquals((await kv.get([
4546
+ "_fedify",
4547
+ "circuit",
4548
+ "hold-give-up.example"
4549
+ ]))?.state, "open");
4550
+ });
4551
+ await t.step("circuit decision errors fall back to retry", async () => {
4552
+ esm_default.hardReset();
4553
+ esm_default.spyGlobal();
4554
+ esm_default.post("https://decision-bookkeeping.example/inbox", {
4555
+ status: 500,
4556
+ body: "server error"
4557
+ });
4558
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 4 }) });
4559
+ const originalGet = kv.get.bind(kv);
4560
+ let getCalls = 0;
4561
+ kv.get = (...args) => {
4562
+ getCalls++;
4563
+ return getCalls === 1 ? originalGet(...args) : Promise.reject(/* @__PURE__ */ new Error("kv get failed"));
4564
+ };
4565
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://decision-bookkeeping.example/inbox"));
4566
+ assertEquals(queued.length, 1);
4567
+ const retry = queued[0].message;
4568
+ assertEquals(retry.attempt, 1);
4569
+ assertEquals(retry.circuitHeld, void 0);
4570
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 4 }));
4571
+ });
4572
+ await t.step("circuit reachable errors keep permanent failure", async () => {
4573
+ esm_default.hardReset();
4574
+ esm_default.spyGlobal();
4575
+ esm_default.post("https://permanent-bookkeeping.example/inbox", {
4576
+ status: 500,
4577
+ body: "server error"
4578
+ });
4579
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { permanentFailureStatusCodes: [500] });
4580
+ await kv.set([
4581
+ "_fedify",
4582
+ "circuit",
4583
+ "permanent-bookkeeping.example"
4584
+ ], {
4585
+ state: "half-open",
4586
+ failures: ["2026-05-25T00:00:00Z"],
4587
+ opened: "2026-05-25T00:00:00Z",
4588
+ halfOpened: "2026-05-25T00:00:00Z"
4589
+ });
4590
+ const originalCas = kv.cas.bind(kv);
4591
+ let casCalls = 0;
4592
+ kv.cas = (...args) => {
4593
+ casCalls++;
4594
+ return casCalls === 1 ? originalCas(...args) : Promise.resolve(false);
4595
+ };
4596
+ let permanentFailureStatusCode;
4597
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
4598
+ permanentFailureStatusCode = values.statusCode;
4599
+ });
4600
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://permanent-bookkeeping.example/inbox"));
4601
+ assertEquals(queued, []);
4602
+ assertEquals(permanentFailureStatusCode, 500);
4603
+ });
4604
+ await t.step("429 respects Retry-After without opening circuit", async () => {
4605
+ esm_default.hardReset();
4606
+ esm_default.spyGlobal();
4607
+ esm_default.post("https://rate.example/inbox", {
4608
+ status: 429,
4609
+ headers: { "Retry-After": "120" },
4610
+ body: "rate limited"
4611
+ });
4612
+ const { federation, queued, kv } = setup({
4613
+ failureThreshold: 1,
4614
+ recoveryDelay: { minutes: 30 }
4615
+ });
4616
+ const orderingKey = "https://example.com/object/rate";
4617
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://rate.example/inbox", { orderingKey }));
4618
+ assertEquals(queued.length, 1);
4619
+ const retry = queued[0].message;
4620
+ assertEquals(retry.attempt, 1);
4621
+ assertEquals(retry.orderingKey, orderingKey);
4622
+ assertEquals(retry.circuitHeld, void 0);
4623
+ assertEquals(queued[0].options?.orderingKey, orderingKey);
4624
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 120 }));
4625
+ assertEquals(await kv.get([
4626
+ "_fedify",
4627
+ "circuit",
4628
+ "rate.example"
4629
+ ]), void 0);
4630
+ });
4631
+ await t.step("429 respects Retry-After with circuit breaker disabled", async () => {
4632
+ esm_default.hardReset();
4633
+ esm_default.spyGlobal();
4634
+ esm_default.post("https://rate-disabled.example/inbox", {
4635
+ status: 429,
4636
+ headers: { "Retry-After": "120" },
4637
+ body: "rate limited"
4638
+ });
4639
+ const { federation, queued, kv } = setup(false, {}, { nativeRetrial: true });
4640
+ assertEquals(federation.circuitBreaker, void 0);
4641
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://rate-disabled.example/inbox", { orderingKey: "https://example.com/object/rate-limited" }));
4642
+ assertEquals(queued.length, 1);
4643
+ const retry = queued[0].message;
4644
+ assertEquals(retry.attempt, 1);
4645
+ assertEquals(retry.circuitHeld, void 0);
4646
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 120 }));
4647
+ assertEquals(queued[0].options?.orderingKey, "https://example.com/object/rate-limited");
4648
+ assertEquals(await kv.get([
4649
+ "_fedify",
4650
+ "circuit",
4651
+ "rate-disabled.example"
4652
+ ]), void 0);
4653
+ });
4654
+ await t.step("429 Retry-After still respects retry give-up", async () => {
4655
+ esm_default.hardReset();
4656
+ esm_default.spyGlobal();
4657
+ esm_default.post("https://give-up.example/inbox", {
4658
+ status: 429,
4659
+ headers: { "Retry-After": "120" },
4660
+ body: "rate limited"
4661
+ });
4662
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => null });
4663
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://give-up.example/inbox"));
4664
+ assertEquals(queued, []);
4665
+ assertEquals(await kv.get([
4666
+ "_fedify",
4667
+ "circuit",
4668
+ "give-up.example"
4669
+ ]), void 0);
4670
+ });
4671
+ await t.step("503 respects Retry-After while counting failure", async () => {
4672
+ esm_default.hardReset();
4673
+ esm_default.spyGlobal();
4674
+ esm_default.post("https://unavailable.example/inbox", {
4675
+ status: 503,
4676
+ headers: { "Retry-After": "120" },
4677
+ body: "temporarily unavailable"
4678
+ });
4679
+ const { federation, queued, kv } = setup({
4680
+ failureThreshold: 5,
4681
+ failureWindow: { minutes: 10 }
4682
+ }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4683
+ const orderingKey = "https://example.com/object/unavailable";
4684
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://unavailable.example/inbox", { orderingKey }));
4685
+ assertEquals(queued.length, 1);
4686
+ const retry = queued[0].message;
4687
+ assertEquals(retry.attempt, 1);
4688
+ assertEquals(retry.circuitHeld, void 0);
4689
+ assertEquals(retry.orderingKey, orderingKey);
4690
+ assertEquals(queued[0].options?.delay, Temporal.Duration.from({ seconds: 120 }));
4691
+ assertEquals(queued[0].options?.orderingKey, orderingKey);
4692
+ const state = await kv.get([
4693
+ "_fedify",
4694
+ "circuit",
4695
+ "unavailable.example"
4696
+ ]);
4697
+ assertEquals(state?.state, "closed");
4698
+ assertEquals((state?.failures).length, 1);
4699
+ });
4700
+ await t.step("503 Retry-After delays newly opened circuit hold", async () => {
4701
+ esm_default.hardReset();
4702
+ esm_default.spyGlobal();
4703
+ esm_default.post("https://open-retry-after.example/inbox", {
4704
+ status: 503,
4705
+ headers: { "Retry-After": "3600" },
4706
+ body: "temporarily unavailable"
4707
+ });
4708
+ const { federation, queued, kv } = setup({
4709
+ failureThreshold: 1,
4710
+ recoveryDelay: { seconds: 30 }
4711
+ }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4712
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://open-retry-after.example/inbox"));
4713
+ assertEquals(queued.length, 1);
4714
+ const held = queued[0].message;
4715
+ assertEquals(held.attempt, 0);
4716
+ assertEquals(held.circuitHeld, true);
4717
+ assertEquals(queued[0].options?.delay?.toString(), "PT3600S");
4718
+ assertEquals((await kv.get([
4719
+ "_fedify",
4720
+ "circuit",
4721
+ "open-retry-after.example"
4722
+ ]))?.state, "open");
4723
+ });
4724
+ await t.step("malformed Retry-After falls back to retry policy", async () => {
4725
+ esm_default.hardReset();
4726
+ esm_default.spyGlobal();
4727
+ esm_default.post("https://huge-retry-after.example/inbox", {
4728
+ status: 429,
4729
+ headers: { "Retry-After": "999999999999999999999999999999" },
4730
+ body: "rate limited"
4731
+ });
4732
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4733
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://huge-retry-after.example/inbox"));
4734
+ assertEquals(queued.length, 1);
4735
+ assertEquals(queued[0].options?.delay?.total({ unit: "second" }), 3);
4736
+ assertEquals(await kv.get([
4737
+ "_fedify",
4738
+ "circuit",
4739
+ "huge-retry-after.example"
4740
+ ]), void 0);
4741
+ });
4742
+ await t.step("invalid Retry-After date falls back to retry policy", async () => {
4743
+ esm_default.hardReset();
4744
+ esm_default.spyGlobal();
4745
+ esm_default.post("https://invalid-retry-after.example/inbox", {
4746
+ status: 429,
4747
+ headers: { "Retry-After": "1.5" },
4748
+ body: "rate limited"
4749
+ });
4750
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4751
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://invalid-retry-after.example/inbox"));
4752
+ assertEquals(queued.length, 1);
4753
+ assertEquals(queued[0].options?.delay?.total({ unit: "second" }), 3);
4754
+ assertEquals(await kv.get([
4755
+ "_fedify",
4756
+ "circuit",
4757
+ "invalid-retry-after.example"
4758
+ ]), void 0);
4759
+ });
4760
+ await t.step("asctime Retry-After date is interpreted as UTC", async () => {
4761
+ esm_default.hardReset();
4762
+ esm_default.spyGlobal();
4763
+ const retryAfter = "Wed Dec 31 23:59:59 2036";
4764
+ esm_default.post("https://asctime-retry-after.example/inbox", {
4765
+ status: 429,
4766
+ headers: { "Retry-After": retryAfter },
4767
+ body: "rate limited"
4768
+ });
4769
+ const { federation, queued } = setup({ failureThreshold: 1 }, { outboxRetryPolicy: () => Temporal.Duration.from({ seconds: 3 }) });
4770
+ const before = Temporal.Now.instant();
4771
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://asctime-retry-after.example/inbox"));
4772
+ const after = Temporal.Now.instant();
4773
+ const retryAtMs = Date.parse(`${retryAfter} GMT`);
4774
+ assertEquals(queued.length, 1);
4775
+ const delayMs = queued[0].options?.delay?.total({ unit: "millisecond" });
4776
+ assertExists(delayMs);
4777
+ assertEquals(delayMs <= retryAtMs - before.epochMilliseconds, true);
4778
+ assertEquals(delayMs >= retryAtMs - after.epochMilliseconds, true);
4779
+ });
4780
+ await t.step("permanent 5xx does not open circuit", async () => {
4781
+ esm_default.hardReset();
4782
+ esm_default.spyGlobal();
4783
+ esm_default.post("https://permanent-500.example/inbox", {
4784
+ status: 500,
4785
+ body: "server error"
4786
+ });
4787
+ const { federation, queued, kv } = setup({ failureThreshold: 1 }, { permanentFailureStatusCodes: [500] });
4788
+ let permanentFailureStatusCode;
4789
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
4790
+ permanentFailureStatusCode = values.statusCode;
4791
+ });
4792
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://permanent-500.example/inbox"));
4793
+ assertEquals(queued, []);
4794
+ assertEquals(permanentFailureStatusCode, 500);
4795
+ assertEquals(await kv.get([
4796
+ "_fedify",
4797
+ "circuit",
4798
+ "permanent-500.example"
4799
+ ]), void 0);
4800
+ });
4801
+ await t.step("permanent 5xx closes half-open circuit", async () => {
4802
+ esm_default.hardReset();
4803
+ esm_default.spyGlobal();
4804
+ esm_default.post("https://permanent-probe.example/inbox", {
4805
+ status: 500,
4806
+ body: "server error"
4807
+ });
4808
+ const { federation, queued, kv } = setup({
4809
+ failureThreshold: 1,
4810
+ releaseInterval: { seconds: 1 }
4811
+ }, { permanentFailureStatusCodes: [500] });
4812
+ await kv.set([
4813
+ "_fedify",
4814
+ "circuit",
4815
+ "permanent-probe.example"
4816
+ ], {
4817
+ state: "half-open",
4818
+ failures: ["2026-05-25T00:00:00Z"],
4819
+ opened: "2026-05-25T00:00:00Z",
4820
+ halfOpened: "2026-05-25T00:00:00Z"
4821
+ });
4822
+ let permanentFailureStatusCode;
4823
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
4824
+ permanentFailureStatusCode = values.statusCode;
4825
+ });
4826
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://permanent-probe.example/inbox"));
4827
+ assertEquals(queued, []);
4828
+ assertEquals(permanentFailureStatusCode, 500);
4829
+ assertEquals(await kv.get([
4830
+ "_fedify",
4831
+ "circuit",
4832
+ "permanent-probe.example"
4833
+ ]), void 0);
4834
+ });
4835
+ await t.step("permanent 4xx closes half-open circuit", async () => {
4836
+ esm_default.hardReset();
4837
+ esm_default.spyGlobal();
4838
+ esm_default.post("https://gone.example/inbox", {
4839
+ status: 410,
4840
+ body: "gone"
4841
+ });
4842
+ const { federation, queued, kv } = setup({
4843
+ failureThreshold: 1,
4844
+ releaseInterval: { seconds: 1 }
4845
+ });
4846
+ await kv.set([
4847
+ "_fedify",
4848
+ "circuit",
4849
+ "gone.example"
4850
+ ], {
4851
+ state: "half-open",
4852
+ failures: ["2026-05-25T00:00:00Z"],
4853
+ opened: "2026-05-25T00:00:00Z",
4854
+ halfOpened: "2026-05-25T00:00:00Z"
4855
+ });
4856
+ let permanentFailureStatusCode;
4857
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
4858
+ permanentFailureStatusCode = values.statusCode;
4859
+ });
4860
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://gone.example/inbox"));
4861
+ assertEquals(queued, []);
4862
+ assertEquals(permanentFailureStatusCode, 410);
4863
+ assertEquals(await kv.get([
4864
+ "_fedify",
4865
+ "circuit",
4866
+ "gone.example"
4867
+ ]), void 0);
4868
+ });
4869
+ await t.step("false disables circuit handling", async () => {
4870
+ esm_default.hardReset();
4871
+ esm_default.spyGlobal();
4872
+ esm_default.post("https://disabled.example/inbox", {
4873
+ status: 500,
4874
+ body: "server error"
4875
+ });
4876
+ const { federation, queued, kv } = setup(false);
4877
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://disabled.example/inbox"));
4878
+ assertEquals(queued.length, 1);
4879
+ const retry = queued[0].message;
4880
+ assertEquals(retry.attempt, 1);
4881
+ assertEquals(retry.circuitHeld, void 0);
4882
+ assertEquals(await kv.get([
4883
+ "_fedify",
4884
+ "circuit",
4885
+ "disabled.example"
4886
+ ]), void 0);
4887
+ });
4888
+ await t.step("state changes are recorded in metrics and spans", async () => {
4889
+ esm_default.hardReset();
4890
+ esm_default.spyGlobal();
4891
+ esm_default.post("https://telemetry.example/inbox", {
4892
+ status: 500,
4893
+ body: "server error"
4894
+ });
4895
+ const [meterProvider, recorder] = createTestMeterProvider();
4896
+ const [tracerProvider, exporter] = createTestTracerProvider();
4897
+ const { federation, queued } = setup({ failureThreshold: 1 }, {
4898
+ meterProvider,
4899
+ tracerProvider
4900
+ });
4901
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://telemetry.example/inbox"));
4902
+ assertEquals(queued.length, 1);
4903
+ const measurements = recorder.getMeasurements("activitypub.circuit_breaker.state_change");
4904
+ assertEquals(measurements.length, 1);
4905
+ assertEquals(measurements[0].attributes["activitypub.remote.host"], "telemetry.example");
4906
+ assertEquals(measurements[0].attributes["activitypub.circuit_breaker.state"], "open");
4907
+ const events = exporter.getEvents("activitypub.outbox", "activitypub.circuit_breaker.state_change");
4908
+ assertEquals(events.length, 1);
4909
+ assertEquals(events[0].attributes?.["activitypub.remote.host"], "telemetry.example");
4910
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.previous_state"], "closed");
4911
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.state"], "open");
4912
+ const heldEvents = exporter.getEvents("activitypub.outbox", "activitypub.circuit_breaker.held");
4913
+ assertEquals(heldEvents.length, 1);
4914
+ assertEquals(heldEvents[0].attributes?.["activitypub.remote.host"], "telemetry.example");
4915
+ assertEquals(heldEvents[0].attributes?.["activitypub.circuit_breaker.state"], "open");
4916
+ });
4917
+ await t.step("held half-open circuit is recorded in spans", async () => {
4918
+ esm_default.hardReset();
4919
+ esm_default.spyGlobal();
4920
+ const now = Temporal.Instant.from("2026-05-25T00:00:30Z");
4921
+ const [tracerProvider, exporter] = createTestTracerProvider();
4922
+ const { federation, queued, kv } = setup({
4923
+ failureThreshold: 1,
4924
+ recoveryDelay: { minutes: 5 },
4925
+ releaseInterval: { minutes: 1 }
4926
+ }, { tracerProvider });
4927
+ federation.circuitBreaker = new CircuitBreaker({
4928
+ kv,
4929
+ prefix: ["_fedify", "circuit"],
4930
+ now: () => now,
4931
+ options: {
4932
+ failureThreshold: 1,
4933
+ recoveryDelay: { minutes: 5 },
4934
+ releaseInterval: { minutes: 1 }
4935
+ }
4936
+ });
4937
+ await kv.set([
4938
+ "_fedify",
4939
+ "circuit",
4940
+ "half-open-telemetry.example"
4941
+ ], {
4942
+ state: "half-open",
4943
+ failures: ["2026-05-25T00:00:00Z"],
4944
+ opened: "2026-05-25T00:00:00Z",
4945
+ halfOpened: "2026-05-25T00:00:00Z"
4946
+ });
4947
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://half-open-telemetry.example/inbox", {
4948
+ circuitHeld: true,
4949
+ circuitHeldSince: "2026-05-25T00:00:00Z"
4950
+ }));
4951
+ assertEquals(queued.length, 1);
4952
+ const events = exporter.getEvents("activitypub.outbox", "activitypub.circuit_breaker.held");
4953
+ assertEquals(events.length, 1);
4954
+ assertEquals(events[0].attributes?.["activitypub.remote.host"], "half-open-telemetry.example");
4955
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.state"], "half_open");
4956
+ });
4957
+ await t.step("stale half-open probe does not record open transition", async () => {
4958
+ esm_default.hardReset();
4959
+ esm_default.spyGlobal();
4960
+ esm_default.post("https://stale-probe-telemetry.example/inbox", {
4961
+ status: 202,
4962
+ body: ""
4963
+ });
4964
+ const now = Temporal.Instant.from("2026-05-25T00:00:02Z");
4965
+ const [tracerProvider, exporter] = createTestTracerProvider();
4966
+ const { federation, kv } = setup({
4967
+ failureThreshold: 1,
4968
+ recoveryDelay: { seconds: 1 }
4969
+ }, { tracerProvider });
4970
+ federation.circuitBreaker = new CircuitBreaker({
4971
+ kv,
4972
+ prefix: ["_fedify", "circuit"],
4973
+ now: () => now,
4974
+ options: {
4975
+ failureThreshold: 1,
4976
+ recoveryDelay: { seconds: 1 }
4977
+ }
4978
+ });
4979
+ await kv.set([
4980
+ "_fedify",
4981
+ "circuit",
4982
+ "stale-probe-telemetry.example"
4983
+ ], {
4984
+ state: "half-open",
4985
+ failures: ["2026-05-25T00:00:00Z"],
4986
+ opened: "2026-05-25T00:00:00Z",
4987
+ halfOpened: "2026-05-25T00:00:00Z"
4988
+ });
4989
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://stale-probe-telemetry.example/inbox"));
4990
+ const events = exporter.getEvents("activitypub.outbox", "activitypub.circuit_breaker.state_change");
4991
+ assertEquals(events.length, 1);
4992
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.previous_state"], "half_open");
4993
+ assertEquals(events[0].attributes?.["activitypub.circuit_breaker.state"], "closed");
4994
+ });
4995
+ await t.step("expired held activity is dropped", async () => {
4996
+ esm_default.hardReset();
4997
+ esm_default.spyGlobal();
4998
+ let dropped = null;
4999
+ const { federation, queued } = setup({
5000
+ failureThreshold: 1,
5001
+ heldActivityTtl: { seconds: 1 },
5002
+ onActivityDrop(remoteHost, details) {
5003
+ dropped = {
5004
+ remoteHost,
5005
+ heldSince: details.heldSince
5006
+ };
5007
+ }
5008
+ });
5009
+ let permanentFailureReason;
5010
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
5011
+ permanentFailureReason = values.reason;
5012
+ });
5013
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://ttl.example/inbox", {
5014
+ circuitHeld: true,
5015
+ circuitHeldSince: "2026-05-25T00:00:00Z"
5016
+ }));
5017
+ assertEquals(queued, []);
5018
+ assertEquals(dropped, {
5019
+ remoteHost: "ttl.example",
5020
+ heldSince: Temporal.Instant.from("2026-05-25T00:00:00Z")
5021
+ });
5022
+ assertEquals(permanentFailureReason, "circuit-breaker-ttl");
5023
+ });
5024
+ await t.step("expired held probe is dropped after failed send", async () => {
5025
+ esm_default.hardReset();
5026
+ esm_default.spyGlobal();
5027
+ let now = Temporal.Instant.from("2026-05-25T00:00:01Z");
5028
+ const heldSince = Temporal.Instant.from("2026-05-25T00:00:00Z");
5029
+ esm_default.post("https://expired-probe.example/inbox", () => {
5030
+ now = Temporal.Instant.from("2026-05-25T00:00:03Z");
5031
+ return {
5032
+ status: 500,
5033
+ body: "server error"
5034
+ };
5035
+ });
5036
+ let dropped = null;
5037
+ const { federation, queued, kv } = setup({
5038
+ failureThreshold: 1,
5039
+ recoveryDelay: { seconds: 1 },
5040
+ heldActivityTtl: { seconds: 2 },
5041
+ releaseInterval: { seconds: 1 }
5042
+ });
5043
+ federation.circuitBreaker = new CircuitBreaker({
5044
+ kv,
5045
+ prefix: ["_fedify", "circuit"],
5046
+ now: () => now,
5047
+ options: {
5048
+ failureThreshold: 1,
5049
+ recoveryDelay: { seconds: 1 },
5050
+ heldActivityTtl: { seconds: 2 },
5051
+ releaseInterval: { seconds: 1 },
5052
+ onActivityDrop(remoteHost, details) {
5053
+ dropped = {
5054
+ remoteHost,
5055
+ heldSince: details.heldSince
5056
+ };
5057
+ }
5058
+ }
5059
+ });
5060
+ await kv.set([
5061
+ "_fedify",
5062
+ "circuit",
5063
+ "expired-probe.example"
5064
+ ], {
5065
+ state: "half-open",
5066
+ failures: ["2026-05-25T00:00:00Z"],
5067
+ opened: "2026-05-25T00:00:00Z",
5068
+ halfOpened: "2026-05-25T00:00:00Z"
5069
+ });
5070
+ let permanentFailureReason;
5071
+ federation.setOutboxPermanentFailureHandler((_ctx, values) => {
5072
+ permanentFailureReason = values.reason;
5073
+ });
5074
+ await federation.processQueuedTask(void 0, createOutboxMessage("https://expired-probe.example/inbox", {
5075
+ circuitHeld: true,
5076
+ circuitHeldSince: heldSince.toString()
5077
+ }));
5078
+ assertEquals(queued, []);
5079
+ assertEquals(dropped, {
5080
+ remoteHost: "expired-probe.example",
5081
+ heldSince
5082
+ });
5083
+ assertEquals(permanentFailureReason, "circuit-breaker-ttl");
5084
+ });
5085
+ esm_default.hardReset();
5086
+ });
4276
5087
  test("FederationImpl.processQueuedTask() queue task metrics", async (t) => {
4277
5088
  await t.step("records failed result when worker re-throws (nativeRetrial)", async () => {
4278
5089
  const kv = new MemoryKvStore();