@fedify/fedify 2.3.0-dev.1184 → 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-xf4uGHKt.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-DQ_yA8Nd.mjs → deno-CoAwVm1I.mjs} +1 -1
  9. package/dist/{docloader-DrvKyR5O.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-fmumSl9Q.cjs → http-BAarxBe5.cjs} +30 -5
  32. package/dist/{http-CevxpgFA.mjs → http-CSwCAQ-H.mjs} +3 -3
  33. package/dist/{http-CYANb3Kf.js → http-Dq_qElWc.js} +25 -6
  34. package/dist/{key-343lgYrZ.mjs → key-DYK_T_PD.mjs} +2 -2
  35. package/dist/{kv-cache-gsEcr_hP.js → kv-cache-BhPocHdd.js} +1 -1
  36. package/dist/{kv-cache-BPzk3HyE.mjs → kv-cache-CFzIDCMJ.mjs} +1 -1
  37. package/dist/{kv-cache-owsCV_hm.cjs → kv-cache-Ds1kjvnu.cjs} +1 -1
  38. package/dist/{ld-DbTiidUm.mjs → ld-BdcT_irA.mjs} +3 -3
  39. package/dist/{metrics-CE6rG2kw.mjs → metrics-Ci97wkob.mjs} +25 -6
  40. package/dist/{middleware-WclBYQsJ.mjs → middleware-BUGT2LmO.mjs} +279 -40
  41. package/dist/{middleware-CbSTUiWU.js → middleware-C-C_I_wJ.js} +615 -32
  42. package/dist/{middleware-DWiKzrOa.cjs → middleware-ddMAHsyF.cjs} +632 -31
  43. package/dist/{middleware-BgTxce56.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-Curbe8kx.mjs → owner-B8ePZh4q.mjs} +2 -2
  52. package/dist/{proof-BgsSe250.cjs → proof-CXdtqYKw.cjs} +1 -1
  53. package/dist/{proof-AVmt2hSm.mjs → proof-CzqluPMh.mjs} +3 -3
  54. package/dist/{proof-CmCCjMkp.js → proof-Dq_RyTjd.js} +1 -1
  55. package/dist/{send-DZIiaTas.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-Fwsn9wyy.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
@@ -0,0 +1,446 @@
1
+ import { Temporal } from "@js-temporal/polyfill";
2
+ import "urlpattern-polyfill";
3
+ globalThis.addEventListener = () => {};
4
+ import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
5
+ import "../std__assert-BBjXFNOb.mjs";
6
+ import { t as assertThrows } from "../assert_throws-BOkhLGYc.mjs";
7
+ import { t as MemoryKvStore } from "../kv-x2IvBUyq.mjs";
8
+ import { n as normalizeCircuitBreakerOptions, r as parseCircuitBreakerKvState, t as CircuitBreaker } from "../circuit-breaker-CSWsyoef.mjs";
9
+ import { test } from "@fedify/fixture";
10
+ //#region src/federation/circuit-breaker.test.ts
11
+ var AlwaysConflictingKvStore = class extends MemoryKvStore {
12
+ attempts = 0;
13
+ cas(_key, _expectedValue, _newValue, _options) {
14
+ this.attempts++;
15
+ if (this.attempts > 10) throw new Error("beforeSend did not stop retrying CAS misses");
16
+ return Promise.resolve(false);
17
+ }
18
+ };
19
+ var CountingCasKvStore = class extends MemoryKvStore {
20
+ attempts = 0;
21
+ cas(key, expectedValue, newValue, options) {
22
+ this.attempts++;
23
+ return super.cas(key, expectedValue, newValue, options);
24
+ }
25
+ };
26
+ test("normalizeCircuitBreakerOptions() uses numeric failure policy", () => {
27
+ const options = normalizeCircuitBreakerOptions({
28
+ failureThreshold: 3,
29
+ failureWindow: { minutes: 10 }
30
+ });
31
+ const failures = [
32
+ Temporal.Instant.from("2026-05-25T00:00:00Z"),
33
+ Temporal.Instant.from("2026-05-25T00:05:00Z"),
34
+ Temporal.Instant.from("2026-05-25T00:10:00Z")
35
+ ];
36
+ assertEquals(options.failure(failures.slice(0, 2)), false);
37
+ assertEquals(options.failure(failures), true);
38
+ assertEquals(options.failure([
39
+ Temporal.Instant.from("2026-05-25T00:00:00Z"),
40
+ Temporal.Instant.from("2026-05-25T00:11:00Z"),
41
+ Temporal.Instant.from("2026-05-25T00:12:00Z")
42
+ ]), false);
43
+ assertEquals(options.pruneFailures([
44
+ Temporal.Instant.from("2026-05-25T00:00:00Z"),
45
+ Temporal.Instant.from("2026-05-25T00:09:00Z"),
46
+ Temporal.Instant.from("2026-05-25T00:10:00Z"),
47
+ Temporal.Instant.from("2026-05-25T00:11:00Z"),
48
+ Temporal.Instant.from("2026-05-25T00:12:00Z")
49
+ ], Temporal.Instant.from("2026-05-25T00:12:00Z")).map((t) => t.toString()), [
50
+ "2026-05-25T00:10:00Z",
51
+ "2026-05-25T00:11:00Z",
52
+ "2026-05-25T00:12:00Z"
53
+ ]);
54
+ });
55
+ test("normalizeCircuitBreakerOptions() validates numeric failure policy", () => {
56
+ assertThrows(() => normalizeCircuitBreakerOptions({ failureThreshold: 0 }), TypeError, "failureThreshold");
57
+ assertThrows(() => normalizeCircuitBreakerOptions({ failureThreshold: 1.5 }), TypeError, "failureThreshold");
58
+ });
59
+ test("normalizeCircuitBreakerOptions() truncates sub-millisecond durations", () => {
60
+ assertEquals(normalizeCircuitBreakerOptions({ recoveryDelay: {
61
+ milliseconds: 1,
62
+ nanoseconds: 5e5
63
+ } }).recoveryDelay, Temporal.Duration.from({ milliseconds: 1 }));
64
+ });
65
+ test("normalizeCircuitBreakerOptions() validates positive durations", () => {
66
+ assertThrows(() => normalizeCircuitBreakerOptions({ recoveryDelay: { seconds: 0 } }), RangeError, "recoveryDelay");
67
+ assertThrows(() => normalizeCircuitBreakerOptions({ heldActivityTtl: { seconds: 0 } }), RangeError, "heldActivityTtl");
68
+ assertThrows(() => normalizeCircuitBreakerOptions({ failureWindow: { seconds: 0 } }), RangeError, "failureWindow");
69
+ assertThrows(() => normalizeCircuitBreakerOptions({ releaseInterval: { seconds: 0 } }), RangeError, "releaseInterval");
70
+ assertThrows(() => normalizeCircuitBreakerOptions({ releaseInterval: { nanoseconds: 5e5 } }), RangeError, "releaseInterval");
71
+ });
72
+ test("normalizeCircuitBreakerOptions() accepts callback failure policy", () => {
73
+ const options = normalizeCircuitBreakerOptions({ failure: (timestamps) => timestamps.length >= 2 });
74
+ const base = Temporal.Instant.from("2026-05-25T00:00:00Z");
75
+ const failures = Array.from({ length: 105 }, (_, i) => base.add({ minutes: i }));
76
+ assertEquals(options.failure([Temporal.Instant.from("2026-05-25T00:00:00Z")]), false);
77
+ assertEquals(options.failure([Temporal.Instant.from("2026-05-25T00:00:00Z"), Temporal.Instant.from("2026-05-25T00:01:00Z")]), true);
78
+ assertEquals(options.pruneFailures(failures, base.add({ minutes: 105 })).map((t) => t.toString()), failures.slice(-100).map((t) => t.toString()));
79
+ });
80
+ test("parseCircuitBreakerKvState() validates stored shape", () => {
81
+ assertEquals(parseCircuitBreakerKvState({
82
+ state: "open",
83
+ failures: ["2026-05-25T00:00:00Z"],
84
+ opened: "2026-05-25T00:00:00Z",
85
+ halfOpened: "2026-05-25T00:00:00Z"
86
+ }), {
87
+ state: "open",
88
+ failures: ["2026-05-25T00:00:00Z"],
89
+ opened: "2026-05-25T00:00:00Z",
90
+ halfOpened: "2026-05-25T00:00:00Z"
91
+ });
92
+ assertEquals(parseCircuitBreakerKvState({ state: "open" }), void 0);
93
+ assertEquals(parseCircuitBreakerKvState({
94
+ state: "other",
95
+ failures: []
96
+ }), void 0);
97
+ assertEquals(parseCircuitBreakerKvState({
98
+ state: "open",
99
+ failures: [],
100
+ opened: 1
101
+ }), void 0);
102
+ assertEquals(parseCircuitBreakerKvState({
103
+ state: "open",
104
+ failures: ["not an instant"]
105
+ }), void 0);
106
+ assertEquals(parseCircuitBreakerKvState({
107
+ state: "open",
108
+ failures: [],
109
+ halfOpened: "not an instant"
110
+ }), void 0);
111
+ });
112
+ test("CircuitBreaker opens, probes, closes, and drops held activities", async () => {
113
+ const kv = new MemoryKvStore();
114
+ let now = Temporal.Instant.from("2026-05-25T00:00:00Z");
115
+ const transitions = [];
116
+ const circuit = new CircuitBreaker({
117
+ kv,
118
+ prefix: ["_fedify", "circuit"],
119
+ now: () => now,
120
+ options: {
121
+ failureThreshold: 2,
122
+ failureWindow: { minutes: 10 },
123
+ recoveryDelay: { minutes: 30 },
124
+ heldActivityTtl: { days: 7 },
125
+ onStateChange(host, previousState, newState) {
126
+ transitions.push(`${host}:${previousState}->${newState}`);
127
+ }
128
+ }
129
+ });
130
+ await circuit.recordFailure("remote.example");
131
+ assertEquals(await circuit.getState("remote.example"), {
132
+ state: "closed",
133
+ failures: ["2026-05-25T00:00:00Z"]
134
+ });
135
+ now = Temporal.Instant.from("2026-05-25T00:05:00Z");
136
+ await circuit.recordFailure("remote.example");
137
+ assertEquals(await circuit.getState("remote.example"), {
138
+ state: "open",
139
+ failures: ["2026-05-25T00:00:00Z", "2026-05-25T00:05:00Z"],
140
+ opened: "2026-05-25T00:05:00Z"
141
+ });
142
+ assertEquals(transitions, ["remote.example:closed->open"]);
143
+ let decision = await circuit.beforeSend("remote.example", {});
144
+ assertEquals(decision, {
145
+ type: "hold",
146
+ delay: Temporal.Duration.from({ minutes: 30 }),
147
+ heldSince: now,
148
+ state: "open"
149
+ });
150
+ now = Temporal.Instant.from("2026-05-25T00:35:00Z");
151
+ decision = await circuit.beforeSend("remote.example", {});
152
+ assertEquals(decision, {
153
+ type: "send",
154
+ probe: true,
155
+ stateChange: {
156
+ previousState: "open",
157
+ newState: "half-open"
158
+ }
159
+ });
160
+ assertEquals(await circuit.getState("remote.example"), {
161
+ state: "half-open",
162
+ failures: ["2026-05-25T00:00:00Z", "2026-05-25T00:05:00Z"],
163
+ opened: "2026-05-25T00:05:00Z",
164
+ halfOpened: "2026-05-25T00:35:00Z"
165
+ });
166
+ await circuit.recordSuccess("remote.example");
167
+ assertEquals(await circuit.getState("remote.example"), void 0);
168
+ assertEquals(transitions, [
169
+ "remote.example:closed->open",
170
+ "remote.example:open->half-open",
171
+ "remote.example:half-open->closed"
172
+ ]);
173
+ decision = await circuit.beforeSend("remote.example", { circuitHeldSince: "2026-05-17T00:00:00Z" });
174
+ assertEquals(decision, {
175
+ type: "drop",
176
+ heldSince: Temporal.Instant.from("2026-05-17T00:00:00Z")
177
+ });
178
+ await kv.set([
179
+ "_fedify",
180
+ "circuit",
181
+ "remote.example"
182
+ ], {
183
+ state: "open",
184
+ failures: ["2026-05-25T00:00:00Z", "2026-05-25T00:05:00Z"],
185
+ opened: "2026-05-25T00:05:00Z"
186
+ });
187
+ decision = await circuit.beforeSend("remote.example", { circuitHeldSince: "2026-05-17T00:00:00Z" });
188
+ assertEquals(decision, {
189
+ type: "drop",
190
+ heldSince: Temporal.Instant.from("2026-05-17T00:00:00Z")
191
+ });
192
+ });
193
+ test("CircuitBreaker recovers stale half-open probes", async () => {
194
+ const kv = new MemoryKvStore();
195
+ let now = Temporal.Instant.from("2026-05-25T00:00:00Z");
196
+ const circuit = new CircuitBreaker({
197
+ kv,
198
+ prefix: ["_fedify", "circuit"],
199
+ now: () => now,
200
+ options: {
201
+ recoveryDelay: { seconds: 30 },
202
+ releaseInterval: { seconds: 5 }
203
+ }
204
+ });
205
+ await kv.set([
206
+ "_fedify",
207
+ "circuit",
208
+ "remote.example"
209
+ ], {
210
+ state: "half-open",
211
+ failures: ["2026-05-24T23:00:00Z"],
212
+ opened: "2026-05-24T23:00:00Z",
213
+ halfOpened: "2026-05-24T23:59:54Z"
214
+ });
215
+ let decision = await circuit.beforeSend("remote.example", {});
216
+ assertEquals(decision, {
217
+ type: "hold",
218
+ state: "half-open",
219
+ delay: Temporal.Duration.from({ seconds: 5 }),
220
+ heldSince: now
221
+ });
222
+ assertEquals(await circuit.getState("remote.example"), {
223
+ state: "half-open",
224
+ failures: ["2026-05-24T23:00:00Z"],
225
+ opened: "2026-05-24T23:00:00Z",
226
+ halfOpened: "2026-05-24T23:59:54Z"
227
+ });
228
+ now = Temporal.Instant.from("2026-05-25T00:00:30Z");
229
+ decision = await circuit.beforeSend("remote.example", {});
230
+ assertEquals(decision, {
231
+ type: "send",
232
+ probe: true
233
+ });
234
+ assertEquals(await circuit.getState("remote.example"), {
235
+ state: "half-open",
236
+ failures: ["2026-05-24T23:00:00Z"],
237
+ opened: "2026-05-24T23:00:00Z",
238
+ halfOpened: "2026-05-25T00:00:30Z"
239
+ });
240
+ });
241
+ test("CircuitBreaker caps held delays at activity TTL", async () => {
242
+ const kv = new MemoryKvStore();
243
+ const now = Temporal.Instant.from("2026-05-25T00:05:00Z");
244
+ const circuit = new CircuitBreaker({
245
+ kv,
246
+ prefix: ["_fedify", "circuit"],
247
+ now: () => now,
248
+ options: {
249
+ recoveryDelay: { minutes: 30 },
250
+ heldActivityTtl: { minutes: 10 },
251
+ releaseInterval: { minutes: 10 }
252
+ }
253
+ });
254
+ await kv.set([
255
+ "_fedify",
256
+ "circuit",
257
+ "new-open.example"
258
+ ], {
259
+ state: "open",
260
+ failures: ["2026-05-25T00:00:00Z"],
261
+ opened: "2026-05-25T00:00:00Z"
262
+ });
263
+ let decision = await circuit.beforeSend("new-open.example", {});
264
+ assertEquals(decision.type, "hold");
265
+ if (decision.type === "hold") {
266
+ assertEquals(decision.state, "open");
267
+ assertEquals(decision.delay.total({ unit: "minute" }), 10);
268
+ assertEquals(decision.heldSince.toString(), "2026-05-25T00:05:00Z");
269
+ }
270
+ await kv.set([
271
+ "_fedify",
272
+ "circuit",
273
+ "open.example"
274
+ ], {
275
+ state: "open",
276
+ failures: ["2026-05-25T00:00:00Z"],
277
+ opened: "2026-05-25T00:00:00Z"
278
+ });
279
+ decision = await circuit.beforeSend("open.example", { circuitHeldSince: "2026-05-25T00:00:00Z" });
280
+ assertEquals(decision.type, "hold");
281
+ if (decision.type === "hold") {
282
+ assertEquals(decision.state, "open");
283
+ assertEquals(decision.delay.total({ unit: "minute" }), 5);
284
+ assertEquals(decision.heldSince.toString(), "2026-05-25T00:00:00Z");
285
+ }
286
+ await kv.set([
287
+ "_fedify",
288
+ "circuit",
289
+ "half-open.example"
290
+ ], {
291
+ state: "half-open",
292
+ failures: ["2026-05-25T00:00:00Z"],
293
+ opened: "2026-05-25T00:00:00Z",
294
+ halfOpened: "2026-05-25T00:00:00Z"
295
+ });
296
+ decision = await circuit.beforeSend("half-open.example", { circuitHeldSince: "2026-05-25T00:00:00Z" });
297
+ assertEquals(decision.type, "hold");
298
+ if (decision.type === "hold") {
299
+ assertEquals(decision.state, "half-open");
300
+ assertEquals(decision.delay.total({ unit: "minute" }), 5);
301
+ assertEquals(decision.heldSince.toString(), "2026-05-25T00:00:00Z");
302
+ }
303
+ });
304
+ test("CircuitBreaker ignores malformed held timestamps", async () => {
305
+ const kv = new MemoryKvStore();
306
+ const now = Temporal.Instant.from("2026-05-25T00:05:00Z");
307
+ const circuit = new CircuitBreaker({
308
+ kv,
309
+ prefix: ["_fedify", "circuit"],
310
+ now: () => now,
311
+ options: { recoveryDelay: { minutes: 30 } }
312
+ });
313
+ await kv.set([
314
+ "_fedify",
315
+ "circuit",
316
+ "malformed-held.example"
317
+ ], {
318
+ state: "open",
319
+ failures: ["2026-05-25T00:00:00Z"],
320
+ opened: "2026-05-25T00:00:00Z"
321
+ });
322
+ assertEquals(await circuit.beforeSend("malformed-held.example", { circuitHeldSince: "not an instant" }), {
323
+ type: "hold",
324
+ state: "open",
325
+ delay: Temporal.Duration.from({ minutes: 25 }),
326
+ heldSince: now
327
+ });
328
+ });
329
+ test("CircuitBreaker bounds beforeSend CAS retries", async () => {
330
+ let kv = new AlwaysConflictingKvStore();
331
+ const now = Temporal.Instant.from("2026-05-25T00:30:00Z");
332
+ let circuit = new CircuitBreaker({
333
+ kv,
334
+ prefix: ["_fedify", "circuit"],
335
+ now: () => now,
336
+ options: {
337
+ recoveryDelay: { minutes: 30 },
338
+ releaseInterval: { seconds: 5 }
339
+ }
340
+ });
341
+ await kv.set([
342
+ "_fedify",
343
+ "circuit",
344
+ "open.example"
345
+ ], {
346
+ state: "open",
347
+ failures: ["2026-05-25T00:00:00Z"],
348
+ opened: "2026-05-25T00:00:00Z"
349
+ });
350
+ let decision = await circuit.beforeSend("open.example", {});
351
+ assertEquals(kv.attempts, 10);
352
+ assertEquals(decision, {
353
+ type: "hold",
354
+ state: "open",
355
+ delay: Temporal.Duration.from({ seconds: 5 }),
356
+ heldSince: now
357
+ });
358
+ kv = new AlwaysConflictingKvStore();
359
+ circuit = new CircuitBreaker({
360
+ kv,
361
+ prefix: ["_fedify", "circuit"],
362
+ now: () => now,
363
+ options: {
364
+ recoveryDelay: { minutes: 30 },
365
+ releaseInterval: { seconds: 5 }
366
+ }
367
+ });
368
+ await kv.set([
369
+ "_fedify",
370
+ "circuit",
371
+ "half-open.example"
372
+ ], {
373
+ state: "half-open",
374
+ failures: ["2026-05-25T00:00:00Z"],
375
+ opened: "2026-05-25T00:00:00Z",
376
+ halfOpened: "2026-05-25T00:00:00Z"
377
+ });
378
+ decision = await circuit.beforeSend("half-open.example", {});
379
+ assertEquals(kv.attempts, 10);
380
+ assertEquals(decision, {
381
+ type: "hold",
382
+ state: "half-open",
383
+ delay: Temporal.Duration.from({ seconds: 5 }),
384
+ heldSince: now
385
+ });
386
+ });
387
+ test("CircuitBreaker skips recording failures for open circuits", async () => {
388
+ const kv = new CountingCasKvStore();
389
+ const circuit = new CircuitBreaker({
390
+ kv,
391
+ prefix: ["_fedify", "circuit"],
392
+ now: () => Temporal.Instant.from("2026-05-25T00:01:00Z")
393
+ });
394
+ await kv.set([
395
+ "_fedify",
396
+ "circuit",
397
+ "open.example"
398
+ ], {
399
+ state: "open",
400
+ failures: ["2026-05-25T00:00:00Z"],
401
+ opened: "2026-05-25T00:00:00Z"
402
+ });
403
+ assertEquals(await circuit.recordFailure("open.example"), void 0);
404
+ assertEquals(kv.attempts, 0);
405
+ assertEquals(await kv.get([
406
+ "_fedify",
407
+ "circuit",
408
+ "open.example"
409
+ ]), {
410
+ state: "open",
411
+ failures: ["2026-05-25T00:00:00Z"],
412
+ opened: "2026-05-25T00:00:00Z"
413
+ });
414
+ });
415
+ test("CircuitBreaker prunes stale closed failure history", async () => {
416
+ const kv = new MemoryKvStore();
417
+ let now = Temporal.Instant.from("2026-05-25T00:00:00Z");
418
+ const circuit = new CircuitBreaker({
419
+ kv,
420
+ prefix: ["_fedify", "circuit"],
421
+ now: () => now,
422
+ options: {
423
+ failureThreshold: 2,
424
+ failureWindow: { minutes: 10 }
425
+ }
426
+ });
427
+ await circuit.recordFailure("sporadic.example");
428
+ assertEquals(await circuit.getState("sporadic.example"), {
429
+ state: "closed",
430
+ failures: ["2026-05-25T00:00:00Z"]
431
+ });
432
+ now = Temporal.Instant.from("2026-05-25T00:20:00Z");
433
+ await circuit.recordFailure("sporadic.example");
434
+ assertEquals(await circuit.getState("sporadic.example"), {
435
+ state: "closed",
436
+ failures: ["2026-05-25T00:20:00Z"]
437
+ });
438
+ now = Temporal.Instant.from("2026-05-25T00:40:00Z");
439
+ await circuit.recordFailure("sporadic.example");
440
+ assertEquals(await circuit.getState("sporadic.example"), {
441
+ state: "closed",
442
+ failures: ["2026-05-25T00:40:00Z"]
443
+ });
444
+ });
445
+ //#endregion
446
+ export {};
@@ -3,7 +3,7 @@ import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
4
  import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
5
5
  import "../std__assert-BBjXFNOb.mjs";
6
- import { n as digest, t as buildCollectionSynchronizationHeader } from "../collection-CA3V5zyK.mjs";
6
+ import { n as digest, t as buildCollectionSynchronizationHeader } from "../collection-Cc3DVAhE.mjs";
7
7
  import { test } from "@fedify/fixture";
8
8
  import { decodeHex } from "byte-encodings/hex";
9
9
  //#region src/federation/collection.test.ts
@@ -8,11 +8,11 @@ import { n as assertGreaterOrEqual, t as assertRejects } from "../assert_rejects
8
8
  import { t as assertInstanceOf } from "../assert_instance_of-DBC5X09g.mjs";
9
9
  import { t as assert } from "../assert-OguE97r2.mjs";
10
10
  import { r as parseAcceptSignature } from "../accept-CceiKpCy.mjs";
11
- import { s as signRequest } from "../http-CevxpgFA.mjs";
11
+ import { s as signRequest } from "../http-CSwCAQ-H.mjs";
12
12
  import { a as rsaPrivateKey3, c as rsaPublicKey3, s as rsaPublicKey2 } from "../keys-C3kae-6B.mjs";
13
- import { a as compactJsonLd, p as signJsonLd } from "../ld-DbTiidUm.mjs";
13
+ import { a as compactJsonLd, p as signJsonLd } from "../ld-BdcT_irA.mjs";
14
14
  import { t as MemoryKvStore } from "../kv-x2IvBUyq.mjs";
15
- import { c as handleActor, d as handleInbox, f as handleObject, h as respondWithObjectIfAcceptable, l as handleCollection, m as respondWithObject, o as createFederation, p as handleOutbox, u as handleCustomCollection } from "../middleware-WclBYQsJ.mjs";
15
+ import { c as handleActor, d as handleInbox, f as handleObject, h as respondWithObjectIfAcceptable, l as handleCollection, m as respondWithObject, o as createFederation, p as handleOutbox, u as handleCustomCollection } from "../middleware-BUGT2LmO.mjs";
16
16
  import { t as ActivityListenerSet } from "../activity-listener-tztVvlNb.mjs";
17
17
  import { Activity, Create, Note, Person, Tombstone } from "@fedify/vocab";
18
18
  import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
@@ -4,9 +4,9 @@ globalThis.addEventListener = () => {};
4
4
  import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
5
5
  import "../std__assert-BBjXFNOb.mjs";
6
6
  import { n as ed25519PrivateKey, r as ed25519PublicKey, t as ed25519Multikey } from "../keys-C3kae-6B.mjs";
7
- import { r as signObject } from "../proof-AVmt2hSm.mjs";
7
+ import { r as signObject } from "../proof-CzqluPMh.mjs";
8
8
  import { t as MemoryKvStore } from "../kv-x2IvBUyq.mjs";
9
- import { o as createFederation } from "../middleware-WclBYQsJ.mjs";
9
+ import { o as createFederation } from "../middleware-BUGT2LmO.mjs";
10
10
  import { Create, Follow, Person } from "@fedify/vocab";
11
11
  import { mockDocumentLoader, test } from "@fedify/fixture";
12
12
  //#region src/federation/idempotency.test.ts
@@ -5,7 +5,7 @@ import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
5
5
  import { t as assertInstanceOf } from "../assert_instance_of-DBC5X09g.mjs";
6
6
  import { t as assert } from "../assert-OguE97r2.mjs";
7
7
  import { t as MemoryKvStore } from "../kv-x2IvBUyq.mjs";
8
- import { t as KvKeyCache } from "../keycache-BYMd8q7F.mjs";
8
+ import { t as KvKeyCache } from "../keycache-BeU0LCII.mjs";
9
9
  import { CryptographicKey, Multikey } from "@fedify/vocab";
10
10
  import { test } from "@fedify/fixture";
11
11
  //#region src/federation/keycache.test.ts
@@ -4,7 +4,7 @@ globalThis.addEventListener = () => {};
4
4
  import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
5
5
  import "../std__assert-BBjXFNOb.mjs";
6
6
  import { t as assertRejects } from "../assert_rejects-DN60FHPX.mjs";
7
- import { _ as recordOutboxActivity, a as instrumentDocumentLoader, c as recordCollectionDispatchDuration, d as recordCollectionTotalItems, f as recordDocumentCache, g as recordKeyLookup, h as recordInboxActivity, l as recordCollectionPageItems, m as recordFanoutRecipients, p as recordDocumentFetch, t as classifyFetchError, u as recordCollectionRequest, v as recordOutboxEnqueue, y as recordWebFingerHandle } from "../metrics-CE6rG2kw.mjs";
7
+ import { _ as recordKeyLookup, a as instrumentDocumentLoader, b as recordWebFingerHandle, c as recordCircuitBreakerStateChange, d as recordCollectionRequest, f as recordCollectionTotalItems, g as recordInboxActivity, h as recordFanoutRecipients, i as getRemoteHost, l as recordCollectionDispatchDuration, m as recordDocumentFetch, p as recordDocumentCache, t as classifyFetchError, u as recordCollectionPageItems, v as recordOutboxActivity, y as recordOutboxEnqueue } from "../metrics-Ci97wkob.mjs";
8
8
  import { createTestMeterProvider, test } from "@fedify/fixture";
9
9
  import { FetchError } from "@fedify/vocab-runtime";
10
10
  //#region src/federation/metrics.test.ts
@@ -16,6 +16,11 @@ const noopQueue = {
16
16
  return Promise.resolve();
17
17
  }
18
18
  };
19
+ test("getRemoteHost() includes non-default ports", () => {
20
+ assertEquals(getRemoteHost(new URL("https://example.com/inbox")), "example.com");
21
+ assertEquals(getRemoteHost(new URL("https://example.com:8443/inbox")), "example.com:8443");
22
+ assertEquals(getRemoteHost(new URL("https://example.com:443/inbox")), "example.com");
23
+ });
19
24
  test("recordFanoutRecipients() records the recipient count with activity type", () => {
20
25
  const [meterProvider, recorder] = createTestMeterProvider();
21
26
  recordFanoutRecipients(meterProvider, 7, "https://www.w3.org/ns/activitystreams#Create");
@@ -105,6 +110,16 @@ test("recordOutboxActivity() records counter with result and activity type", ()
105
110
  "abandoned"
106
111
  ]);
107
112
  });
113
+ test("recordCircuitBreakerStateChange() records counter with bounded attributes", () => {
114
+ const [meterProvider, recorder] = createTestMeterProvider();
115
+ recordCircuitBreakerStateChange(meterProvider, "remote.example", "half_open");
116
+ const measurements = recorder.getMeasurements("activitypub.circuit_breaker.state_change");
117
+ assertEquals(measurements.length, 1);
118
+ assertEquals(measurements[0].type, "counter");
119
+ assertEquals(measurements[0].value, 1);
120
+ assertEquals(measurements[0].attributes["activitypub.remote.host"], "remote.example");
121
+ assertEquals(measurements[0].attributes["activitypub.circuit_breaker.state"], "half_open");
122
+ });
108
123
  test("recordKeyLookup() records counter and duration with all attributes", () => {
109
124
  const [meterProvider, recorder] = createTestMeterProvider();
110
125
  recordKeyLookup(meterProvider, {