@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
@@ -2,10 +2,10 @@ const { Temporal } = require("@js-temporal/polyfill");
2
2
  const { URLPattern } = require("urlpattern-polyfill");
3
3
  const require_chunk = require("./chunk-DDcVe30Y.cjs");
4
4
  const require_transformers = require("./transformers-NeAONrAq.cjs");
5
- const require_http = require("./http-fmumSl9Q.cjs");
6
- const require_proof = require("./proof-BgsSe250.cjs");
5
+ const require_http = require("./http-BAarxBe5.cjs");
6
+ const require_proof = require("./proof-CXdtqYKw.cjs");
7
7
  const require_types = require("./types-KC4QAoxe.cjs");
8
- const require_kv_cache = require("./kv-cache-owsCV_hm.cjs");
8
+ const require_kv_cache = require("./kv-cache-Ds1kjvnu.cjs");
9
9
  let _logtape_logtape = require("@logtape/logtape");
10
10
  let _fedify_uri_template = require("@fedify/uri-template");
11
11
  let _fedify_vocab = require("@fedify/vocab");
@@ -653,6 +653,338 @@ function createFederationBuilder() {
653
653
  return new FederationBuilderImpl();
654
654
  }
655
655
  //#endregion
656
+ //#region src/federation/circuit-breaker.ts
657
+ const MAX_CUSTOM_FAILURE_HISTORY = 100;
658
+ /**
659
+ * Tracks reachability state for remote outbox delivery hosts.
660
+ * @since 2.3.0
661
+ */
662
+ var CircuitBreaker = class {
663
+ #kv;
664
+ #prefix;
665
+ #options;
666
+ #now;
667
+ #stateChangeObserver;
668
+ constructor(options) {
669
+ this.#kv = options.kv;
670
+ this.#prefix = options.prefix;
671
+ this.#options = normalizeCircuitBreakerOptions(options.options ?? {});
672
+ this.#now = options.now ?? (() => Temporal.Now.instant());
673
+ this.#stateChangeObserver = options.stateChangeObserver;
674
+ }
675
+ get options() {
676
+ return this.#options;
677
+ }
678
+ capHeldDelay(heldSince, delay) {
679
+ const now = this.#now();
680
+ return now.until(this.#capHeldRetryAt(now, heldSince, now.add(delay)));
681
+ }
682
+ async beforeSend(remoteHost, message) {
683
+ const heldSince = parseHeldSince(message.circuitHeldSince);
684
+ const now = this.#now();
685
+ if (heldSince != null && Temporal.Instant.compare(heldSince.add(this.#options.heldActivityTtl), now) <= 0) return {
686
+ type: "drop",
687
+ heldSince
688
+ };
689
+ let lastConflictingState;
690
+ for (let attempt = 0; attempt < 10; attempt++) {
691
+ const oldState = await this.#get(remoteHost);
692
+ if (oldState == null || oldState.state === "closed") return {
693
+ type: "send",
694
+ probe: false
695
+ };
696
+ if (oldState.state === "half-open") {
697
+ const halfOpened = oldState.halfOpened == null ? void 0 : Temporal.Instant.from(oldState.halfOpened);
698
+ if (halfOpened != null) {
699
+ const staleAt = halfOpened.add(this.#options.recoveryDelay);
700
+ if (Temporal.Instant.compare(now, staleAt) < 0) {
701
+ const releaseAt = now.add(this.#options.releaseInterval);
702
+ const retryAt = Temporal.Instant.compare(releaseAt, staleAt) < 0 ? releaseAt : staleAt;
703
+ const cappedRetryAt = this.#capHeldRetryAt(now, heldSince, retryAt);
704
+ return {
705
+ type: "hold",
706
+ state: "half-open",
707
+ delay: now.until(cappedRetryAt),
708
+ heldSince: heldSince ?? now
709
+ };
710
+ }
711
+ }
712
+ const newState = {
713
+ ...oldState,
714
+ state: "half-open",
715
+ halfOpened: now.toString()
716
+ };
717
+ if (await this.#replace(remoteHost, oldState, newState)) return {
718
+ type: "send",
719
+ probe: true
720
+ };
721
+ lastConflictingState = "half-open";
722
+ continue;
723
+ }
724
+ const probeAt = (oldState.opened == null ? now : Temporal.Instant.from(oldState.opened)).add(this.#options.recoveryDelay);
725
+ if (Temporal.Instant.compare(now, probeAt) < 0) {
726
+ const retryAt = this.#capHeldRetryAt(now, heldSince, probeAt);
727
+ return {
728
+ type: "hold",
729
+ state: "open",
730
+ delay: now.until(retryAt),
731
+ heldSince: heldSince ?? now
732
+ };
733
+ }
734
+ const newState = {
735
+ ...oldState,
736
+ state: "half-open",
737
+ halfOpened: now.toString()
738
+ };
739
+ if (await this.#replace(remoteHost, oldState, newState)) {
740
+ await this.#notifyStateChange(remoteHost, "open", "half-open");
741
+ return {
742
+ type: "send",
743
+ probe: true,
744
+ stateChange: {
745
+ previousState: "open",
746
+ newState: "half-open"
747
+ }
748
+ };
749
+ }
750
+ lastConflictingState = "open";
751
+ }
752
+ if (lastConflictingState != null) {
753
+ const retryAt = this.#capHeldRetryAt(now, heldSince, now.add(this.#options.releaseInterval));
754
+ return {
755
+ type: "hold",
756
+ state: lastConflictingState,
757
+ delay: now.until(retryAt),
758
+ heldSince: heldSince ?? now
759
+ };
760
+ }
761
+ throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
762
+ }
763
+ async recordSuccess(remoteHost) {
764
+ for (let attempt = 0; attempt < 10; attempt++) {
765
+ const oldState = await this.#get(remoteHost);
766
+ if (oldState == null) return void 0;
767
+ if (await this.#replace(remoteHost, oldState, void 0)) {
768
+ if (oldState.state !== "closed") {
769
+ await this.#notifyStateChange(remoteHost, oldState.state, "closed");
770
+ return {
771
+ previousState: oldState.state,
772
+ newState: "closed"
773
+ };
774
+ }
775
+ return;
776
+ }
777
+ }
778
+ throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
779
+ }
780
+ async recordReachableFailure(remoteHost) {
781
+ return await this.recordSuccess(remoteHost);
782
+ }
783
+ async recordFailure(remoteHost) {
784
+ const now = this.#now();
785
+ for (let attempt = 0; attempt < 10; attempt++) {
786
+ const oldState = await this.#get(remoteHost);
787
+ if (oldState?.state === "open") return void 0;
788
+ const oldFailures = oldState?.failures.map(Temporal.Instant.from) ?? [];
789
+ const failures = this.#options.pruneFailures([...oldFailures, now], now);
790
+ let newState;
791
+ let transition;
792
+ if (oldState?.state === "half-open" || this.#options.failure(failures)) {
793
+ newState = {
794
+ state: "open",
795
+ failures: failures.map((t) => t.toString()),
796
+ opened: now.toString()
797
+ };
798
+ transition = [oldState?.state ?? "closed", "open"];
799
+ } else newState = {
800
+ state: "closed",
801
+ failures: failures.map((t) => t.toString())
802
+ };
803
+ if (await this.#replace(remoteHost, oldState, newState)) {
804
+ if (transition != null) {
805
+ await this.#notifyStateChange(remoteHost, transition[0], transition[1]);
806
+ return {
807
+ previousState: transition[0],
808
+ newState: transition[1]
809
+ };
810
+ }
811
+ return;
812
+ }
813
+ }
814
+ throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
815
+ }
816
+ async dropActivity(remoteHost, details) {
817
+ try {
818
+ await this.#options.onActivityDrop?.(remoteHost, details);
819
+ } catch (error) {
820
+ (0, _logtape_logtape.getLogger)([
821
+ "fedify",
822
+ "federation",
823
+ "circuit"
824
+ ]).error("An unexpected error occurred in circuit breaker activity drop handler:\n{error}", {
825
+ remoteHost,
826
+ error
827
+ });
828
+ }
829
+ }
830
+ async getState(remoteHost) {
831
+ return await this.#get(remoteHost);
832
+ }
833
+ #key(remoteHost) {
834
+ return [...this.#prefix, remoteHost];
835
+ }
836
+ #capHeldRetryAt(now, heldSince, retryAt) {
837
+ const expiresAt = (heldSince ?? now).add(this.#options.heldActivityTtl);
838
+ return Temporal.Instant.compare(expiresAt, retryAt) < 0 ? expiresAt : retryAt;
839
+ }
840
+ async #get(remoteHost) {
841
+ return parseCircuitBreakerKvState(await this.#kv.get(this.#key(remoteHost)));
842
+ }
843
+ async #replace(remoteHost, oldState, newState) {
844
+ const key = this.#key(remoteHost);
845
+ if (this.#kv.cas == null) {
846
+ if (newState == null) await this.#kv.delete(key);
847
+ else await this.#kv.set(key, newState);
848
+ return true;
849
+ }
850
+ return await this.#kv.cas(key, oldState, newState);
851
+ }
852
+ async #notifyStateChange(remoteHost, previousState, newState) {
853
+ try {
854
+ await this.#options.onStateChange?.(remoteHost, previousState, newState);
855
+ } catch (error) {
856
+ (0, _logtape_logtape.getLogger)([
857
+ "fedify",
858
+ "federation",
859
+ "circuit"
860
+ ]).error("An unexpected error occurred in circuit breaker state change handler:\n{error}", {
861
+ remoteHost,
862
+ previousState,
863
+ newState,
864
+ error
865
+ });
866
+ }
867
+ try {
868
+ await this.#stateChangeObserver?.(remoteHost, previousState, newState);
869
+ } catch (error) {
870
+ (0, _logtape_logtape.getLogger)([
871
+ "fedify",
872
+ "federation",
873
+ "circuit"
874
+ ]).error("An unexpected error occurred in circuit breaker state change observer:\n{error}", {
875
+ remoteHost,
876
+ previousState,
877
+ newState,
878
+ error
879
+ });
880
+ }
881
+ }
882
+ };
883
+ /**
884
+ * Normalizes user-provided circuit breaker options into the internal policy
885
+ * shape used while processing queued outbox deliveries.
886
+ *
887
+ * @param options The public circuit breaker options supplied to Fedify.
888
+ * @returns The normalized failure predicate, failure pruning function,
889
+ * duration values, and optional callbacks with defaults applied.
890
+ * @throws {RangeError} If any configured duration is not positive.
891
+ * @throws {TypeError} If `failureThreshold` is not a positive integer.
892
+ */
893
+ function normalizeCircuitBreakerOptions(options) {
894
+ const recoveryDelay = toInstantDuration(options.recoveryDelay ?? { minutes: 30 });
895
+ const heldActivityTtl = toInstantDuration(options.heldActivityTtl ?? { hours: 168 });
896
+ const releaseInterval = toInstantDuration(options.releaseInterval ?? { seconds: 1 });
897
+ assertPositiveDuration(recoveryDelay, "recoveryDelay");
898
+ assertPositiveDuration(heldActivityTtl, "heldActivityTtl");
899
+ assertPositiveDuration(releaseInterval, "releaseInterval");
900
+ let failure;
901
+ let pruneFailures;
902
+ if (options.failure == null) {
903
+ const failureThreshold = options.failureThreshold ?? 5;
904
+ if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) throw new TypeError("failureThreshold must be a positive integer.");
905
+ const failureWindow = toInstantDuration(options.failureWindow ?? { minutes: 10 });
906
+ assertPositiveDuration(failureWindow, "failureWindow");
907
+ pruneFailures = (timestamps, now) => {
908
+ const earliest = now.subtract(failureWindow);
909
+ return timestamps.filter((timestamp) => Temporal.Instant.compare(timestamp, earliest) >= 0).slice(-failureThreshold);
910
+ };
911
+ failure = (timestamps) => {
912
+ if (timestamps.length < failureThreshold) return false;
913
+ const first = timestamps[timestamps.length - failureThreshold];
914
+ const last = timestamps[timestamps.length - 1];
915
+ return Temporal.Duration.compare(first.until(last), failureWindow) <= 0;
916
+ };
917
+ } else {
918
+ failure = options.failure;
919
+ pruneFailures = (timestamps) => timestamps.slice(-MAX_CUSTOM_FAILURE_HISTORY);
920
+ }
921
+ return {
922
+ failure,
923
+ pruneFailures,
924
+ recoveryDelay,
925
+ heldActivityTtl,
926
+ releaseInterval,
927
+ onStateChange: options.onStateChange,
928
+ onActivityDrop: options.onActivityDrop
929
+ };
930
+ }
931
+ function toInstantDuration(duration) {
932
+ const parsed = Temporal.Duration.from(duration);
933
+ return Temporal.Duration.from({ milliseconds: Math.trunc(parsed.total({
934
+ unit: "millisecond",
935
+ relativeTo: Temporal.PlainDateTime.from("2026-01-01T00:00:00")
936
+ })) });
937
+ }
938
+ function assertPositiveDuration(duration, name) {
939
+ if (Temporal.Duration.compare(duration, { seconds: 0 }) <= 0) throw new RangeError(`${name} must be a positive duration.`);
940
+ }
941
+ function parseHeldSince(value) {
942
+ if (value == null) return void 0;
943
+ try {
944
+ return Temporal.Instant.from(value);
945
+ } catch (error) {
946
+ (0, _logtape_logtape.getLogger)([
947
+ "fedify",
948
+ "federation",
949
+ "circuit"
950
+ ]).warn("Invalid circuitHeldSince value in queued outbox message: {value}", {
951
+ value,
952
+ error
953
+ });
954
+ return;
955
+ }
956
+ }
957
+ /**
958
+ * Parses a value loaded from the circuit breaker KV store.
959
+ *
960
+ * @param value The raw KV value to validate.
961
+ * @returns A circuit breaker state when `value` has a recognized state and
962
+ * valid instant strings, or `undefined` when the stored value is malformed.
963
+ */
964
+ function parseCircuitBreakerKvState(value) {
965
+ const isInstantString = (v) => {
966
+ if (typeof v !== "string") return false;
967
+ try {
968
+ Temporal.Instant.from(v);
969
+ return true;
970
+ } catch {
971
+ return false;
972
+ }
973
+ };
974
+ if (typeof value !== "object" || value == null) return void 0;
975
+ const record = value;
976
+ if (record.state !== "closed" && record.state !== "open" && record.state !== "half-open") return;
977
+ if (!Array.isArray(record.failures) || !record.failures.every((failure) => isInstantString(failure))) return;
978
+ if (record.opened != null && !isInstantString(record.opened)) return;
979
+ if (record.halfOpened != null && !isInstantString(record.halfOpened)) return;
980
+ return {
981
+ state: record.state,
982
+ failures: record.failures,
983
+ ...record.opened == null ? {} : { opened: record.opened },
984
+ ...record.halfOpened == null ? {} : { halfOpened: record.halfOpened }
985
+ };
986
+ }
987
+ //#endregion
656
988
  //#region src/federation/collection.ts
657
989
  /**
658
990
  * Calculates the [partial follower collection digest][1].
@@ -2911,13 +3243,14 @@ async function sendActivityInternal({ activity, activityId, activityType, keys,
2911
3243
  specDeterminer
2912
3244
  });
2913
3245
  } catch (error) {
3246
+ const transportError = error instanceof _fedify_vocab_runtime.FetchError ? error : createFetchError(inbox.href, error);
2914
3247
  logger.error("Failed to send activity {activityId} to {inbox}:\n{error}", {
2915
3248
  activityId,
2916
3249
  inbox: inbox.href,
2917
- error
3250
+ error: transportError
2918
3251
  });
2919
3252
  federationMetrics.recordDelivery(inbox, require_http.getDurationMs(started), false, activityType);
2920
- throw error;
3253
+ throw transportError;
2921
3254
  }
2922
3255
  try {
2923
3256
  if (!response.ok) {
@@ -2934,7 +3267,7 @@ async function sendActivityInternal({ activity, activityId, activityType, keys,
2934
3267
  statusText: response.statusText,
2935
3268
  error
2936
3269
  });
2937
- throw new SendActivityError(inbox, response.status, `Failed to send activity ${activityId} to ${inbox.href} (${response.status} ${response.statusText}):\n${error}`, error);
3270
+ throw new SendActivityError(inbox, response.status, `Failed to send activity ${activityId} to ${inbox.href} (${response.status} ${response.statusText}):\n${error}`, error, response.headers);
2938
3271
  }
2939
3272
  deliverySuccess = true;
2940
3273
  const eventAttributes = {
@@ -2949,6 +3282,11 @@ async function sendActivityInternal({ activity, activityId, activityType, keys,
2949
3282
  federationMetrics.recordDelivery(inbox, require_http.getDurationMs(started), deliverySuccess, activityType);
2950
3283
  }
2951
3284
  }
3285
+ function createFetchError(url, cause) {
3286
+ const error = new _fedify_vocab_runtime.FetchError(url, cause instanceof Error ? cause.message : String(cause));
3287
+ error.cause = cause;
3288
+ return error;
3289
+ }
2952
3290
  /**
2953
3291
  * An error that is thrown when an activity fails to send to a remote inbox.
2954
3292
  * It contains structured information about the failure, including the HTTP
@@ -2972,18 +3310,25 @@ var SendActivityError = class extends Error {
2972
3310
  */
2973
3311
  responseBody;
2974
3312
  /**
3313
+ * The response headers from the inbox.
3314
+ * @since 2.3.0
3315
+ */
3316
+ responseHeaders;
3317
+ /**
2975
3318
  * Creates a new {@link SendActivityError}.
2976
3319
  * @param inbox The inbox URL.
2977
3320
  * @param statusCode The HTTP status code.
2978
3321
  * @param message The error message.
2979
3322
  * @param responseBody The response body.
3323
+ * @param responseHeaders The response headers.
2980
3324
  */
2981
- constructor(inbox, statusCode, message, responseBody) {
3325
+ constructor(inbox, statusCode, message, responseBody, responseHeaders) {
2982
3326
  super(message);
2983
3327
  this.name = "SendActivityError";
2984
3328
  this.inbox = inbox;
2985
3329
  this.statusCode = statusCode;
2986
3330
  this.responseBody = responseBody;
3331
+ this.responseHeaders = new Headers(responseHeaders);
2987
3332
  }
2988
3333
  };
2989
3334
  //#endregion
@@ -3182,6 +3527,57 @@ var middleware_exports = /* @__PURE__ */ require_chunk.__exportAll({
3182
3527
  OutboxContextImpl: () => OutboxContextImpl,
3183
3528
  createFederation: () => createFederation
3184
3529
  });
3530
+ const circuitBreakerCasWarningKvStores = /* @__PURE__ */ new WeakSet();
3531
+ const retryAfterHttpDate = /* @__PURE__ */ new RegExp("^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \\d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \\d{4} \\d{2}:\\d{2}:\\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \\d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: \\d|\\d{2}) \\d{2}:\\d{2}:\\d{2} \\d{4})$");
3532
+ function parseRetryAfter(headers, now = Temporal.Now.instant()) {
3533
+ const value = headers.get("Retry-After");
3534
+ if (value == null) return void 0;
3535
+ const trimmed = value.trim();
3536
+ if (/^\d+$/.test(trimmed)) {
3537
+ const seconds = Number(trimmed);
3538
+ if (!Number.isFinite(seconds)) return void 0;
3539
+ return parseRetryAfterDuration({ seconds });
3540
+ }
3541
+ if (!retryAfterHttpDate.test(trimmed)) return void 0;
3542
+ const httpDate = trimmed.endsWith("GMT") ? trimmed : `${trimmed} GMT`;
3543
+ const retryAtMs = Date.parse(httpDate);
3544
+ if (Number.isNaN(retryAtMs)) return void 0;
3545
+ const nowMs = Number(now.epochMilliseconds);
3546
+ return parseRetryAfterDuration({ milliseconds: Math.max(0, retryAtMs - nowMs) });
3547
+ }
3548
+ function parseRetryAfterDuration(durationLike) {
3549
+ try {
3550
+ return Temporal.Duration.from(durationLike);
3551
+ } catch (error) {
3552
+ if (error instanceof RangeError) return void 0;
3553
+ throw error;
3554
+ }
3555
+ }
3556
+ function clampNegativeDelay(delay) {
3557
+ return delay.sign < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay;
3558
+ }
3559
+ function maxDelay(first, second) {
3560
+ return Temporal.Duration.compare(first, second) >= 0 ? first : second;
3561
+ }
3562
+ function isTransportDeliveryError(error) {
3563
+ return error instanceof _fedify_vocab_runtime.FetchError || require_http.isAbortError(error);
3564
+ }
3565
+ function toCircuitBreakerMetricState(state) {
3566
+ return state === "half-open" ? "half_open" : state;
3567
+ }
3568
+ function recordCircuitBreakerSpanEvent(span, remoteHost, change) {
3569
+ span.addEvent("activitypub.circuit_breaker.state_change", {
3570
+ "activitypub.remote.host": remoteHost,
3571
+ "activitypub.circuit_breaker.previous_state": toCircuitBreakerMetricState(change.previousState),
3572
+ "activitypub.circuit_breaker.state": toCircuitBreakerMetricState(change.newState)
3573
+ });
3574
+ }
3575
+ function recordCircuitBreakerHeldSpanEvent(span, remoteHost, state) {
3576
+ span.addEvent("activitypub.circuit_breaker.held", {
3577
+ "activitypub.remote.host": remoteHost,
3578
+ "activitypub.circuit_breaker.state": toCircuitBreakerMetricState(state)
3579
+ });
3580
+ }
3185
3581
  function isRemoteContextLoadingFailure(error) {
3186
3582
  return error instanceof Error && typeof error.details === "object" && error.details != null && error.details.code === "loading remote context failed";
3187
3583
  }
@@ -3225,6 +3621,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3225
3621
  skipSignatureVerification;
3226
3622
  outboxRetryPolicy;
3227
3623
  inboxRetryPolicy;
3624
+ circuitBreaker;
3228
3625
  activityTransformers;
3229
3626
  _tracerProvider;
3230
3627
  _meterProvider;
@@ -3239,6 +3636,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3239
3636
  publicKey: ["_fedify", "publicKey"],
3240
3637
  httpMessageSignaturesSpec: ["_fedify", "httpMessageSignaturesSpec"],
3241
3638
  acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"],
3639
+ circuitBreaker: ["_fedify", "circuit"],
3242
3640
  ...options.kvPrefixes ?? {}
3243
3641
  };
3244
3642
  if (options.queue == null) {
@@ -3254,6 +3652,25 @@ var FederationImpl = class extends FederationBuilderImpl {
3254
3652
  this.outboxQueue = options.queue.outbox;
3255
3653
  this.fanoutQueue = options.queue.fanout;
3256
3654
  }
3655
+ if (options.circuitBreaker !== false && this.outboxQueue != null) {
3656
+ this.circuitBreaker = new CircuitBreaker({
3657
+ kv: options.kv,
3658
+ prefix: this.kvPrefixes.circuitBreaker,
3659
+ options: options.circuitBreaker,
3660
+ stateChangeObserver: (remoteHost, _previousState, newState) => {
3661
+ const metricState = toCircuitBreakerMetricState(newState);
3662
+ require_http.recordCircuitBreakerStateChange(this.meterProvider, remoteHost, metricState);
3663
+ }
3664
+ });
3665
+ if (options.kv.cas == null && !circuitBreakerCasWarningKvStores.has(options.kv)) {
3666
+ circuitBreakerCasWarningKvStores.add(options.kv);
3667
+ (0, _logtape_logtape.getLogger)([
3668
+ "fedify",
3669
+ "federation",
3670
+ "circuit"
3671
+ ]).warn("The configured key-value store does not support CAS; outbound delivery circuit breaker updates may race under concurrent workers.");
3672
+ }
3673
+ }
3257
3674
  this.inboxQueueStarted = false;
3258
3675
  this.outboxQueueStarted = false;
3259
3676
  this.fanoutQueueStarted = false;
@@ -3562,19 +3979,129 @@ var FederationImpl = class extends FederationBuilderImpl {
3562
3979
  if (rsaKeyPair == null && pair.privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") rsaKeyPair = pair;
3563
3980
  keys.push(pair);
3564
3981
  }
3982
+ const loaderOptions = this.#getLoaderOptions(message.baseUrl);
3983
+ let parsedActorIds;
3984
+ const getActorIds = () => {
3985
+ parsedActorIds ??= (message.actorIds ?? []).flatMap((id) => {
3986
+ try {
3987
+ return [new URL(id)];
3988
+ } catch {
3989
+ logger.warn("Invalid actorId URL in OutboxMessage: {id}", { id });
3990
+ return [];
3991
+ }
3992
+ });
3993
+ return parsedActorIds;
3994
+ };
3995
+ const parseActivity = () => _fedify_vocab.Activity.fromJsonLd(message.activity, {
3996
+ contextLoader: this.contextLoaderFactory(loaderOptions),
3997
+ documentLoader: rsaKeyPair == null ? this.documentLoaderFactory(loaderOptions) : this.authenticatedDocumentLoaderFactory(rsaKeyPair, loaderOptions),
3998
+ tracerProvider: this.tracerProvider
3999
+ });
4000
+ const enqueueHeldOutboxMessage = async (delay, heldSince) => {
4001
+ const { outboxQueue } = this;
4002
+ if (outboxQueue == null) return;
4003
+ const heldMessage = {
4004
+ ...message,
4005
+ circuitHeld: true,
4006
+ circuitHeldSince: heldSince.toString()
4007
+ };
4008
+ await outboxQueue.enqueue(heldMessage, {
4009
+ delay: clampNegativeDelay(delay),
4010
+ orderingKey: message.orderingKey
4011
+ });
4012
+ require_http.getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
4013
+ role: "outbox",
4014
+ queue: outboxQueue,
4015
+ activityType: heldMessage.activityType
4016
+ }, heldMessage.attempt);
4017
+ };
4018
+ const dropHeldOutboxMessage = async (circuit, remoteHost, inbox, heldSince, activity) => {
4019
+ await circuit.dropActivity(remoteHost, {
4020
+ inbox,
4021
+ activity,
4022
+ activityId: message.activityId,
4023
+ activityType: message.activityType,
4024
+ actorIds: getActorIds(),
4025
+ heldSince
4026
+ });
4027
+ if (this.outboxPermanentFailureHandler != null) {
4028
+ const ctx = this.#createContext(new URL(message.baseUrl), _, { documentLoader: this.documentLoaderFactory(loaderOptions) });
4029
+ try {
4030
+ await this.outboxPermanentFailureHandler(ctx, {
4031
+ reason: "circuit-breaker-ttl",
4032
+ inbox,
4033
+ activity,
4034
+ error: new SendActivityError(inbox, 0, "Circuit breaker held activity expired.", ""),
4035
+ statusCode: 0,
4036
+ circuitHeldSince: heldSince,
4037
+ actorIds: getActorIds()
4038
+ });
4039
+ } catch (handlerError) {
4040
+ logger.error("An unexpected error occurred in outboxPermanentFailureHandler:\n{error}", {
4041
+ ...logData,
4042
+ error: handlerError
4043
+ });
4044
+ }
4045
+ }
4046
+ require_http.recordOutboxActivity(this.meterProvider, "abandoned", message.activityType);
4047
+ };
3565
4048
  try {
4049
+ const inbox = new URL(message.inbox);
4050
+ const circuit = this.outboxQueue == null ? void 0 : this.circuitBreaker;
4051
+ const remoteHost = require_http.getRemoteHost(inbox);
4052
+ let decision;
4053
+ if (circuit != null) try {
4054
+ decision = await circuit.beforeSend(remoteHost, message);
4055
+ } catch (circuitError) {
4056
+ (0, _logtape_logtape.getLogger)([
4057
+ "fedify",
4058
+ "federation",
4059
+ "circuit"
4060
+ ]).error("Failed to check circuit breaker state before sending; proceeding with delivery:\n{error}", {
4061
+ ...logData,
4062
+ remoteHost,
4063
+ error: circuitError
4064
+ });
4065
+ }
4066
+ if (decision != null && circuit != null) {
4067
+ if (decision.type === "hold") {
4068
+ recordCircuitBreakerHeldSpanEvent(span, remoteHost, decision.state);
4069
+ await enqueueHeldOutboxMessage(decision.delay, decision.heldSince);
4070
+ return;
4071
+ }
4072
+ if (decision.type === "drop") {
4073
+ const activity = await parseActivity();
4074
+ await dropHeldOutboxMessage(circuit, remoteHost, inbox, decision.heldSince, activity);
4075
+ return;
4076
+ }
4077
+ if (decision.stateChange != null) recordCircuitBreakerSpanEvent(span, remoteHost, decision.stateChange);
4078
+ }
3566
4079
  await sendActivity({
3567
4080
  keys,
3568
4081
  activity: message.activity,
3569
4082
  activityId: message.activityId,
3570
4083
  activityType: message.activityType,
3571
- inbox: new URL(message.inbox),
4084
+ inbox,
3572
4085
  sharedInbox: message.sharedInbox,
3573
4086
  headers: new Headers(message.headers),
3574
4087
  specDeterminer: new KvSpecDeterminer(this.kv, this.kvPrefixes.httpMessageSignaturesSpec, this.firstKnock),
3575
4088
  meterProvider: this.meterProvider,
3576
4089
  tracerProvider: this.tracerProvider
3577
4090
  });
4091
+ if (circuit != null) try {
4092
+ const stateChange = await circuit.recordSuccess(remoteHost);
4093
+ if (stateChange != null) recordCircuitBreakerSpanEvent(span, remoteHost, stateChange);
4094
+ } catch (error) {
4095
+ (0, _logtape_logtape.getLogger)([
4096
+ "fedify",
4097
+ "federation",
4098
+ "circuit"
4099
+ ]).error("Failed to record successful delivery in circuit breaker state; the activity was already delivered:\n{error}", {
4100
+ ...logData,
4101
+ remoteHost,
4102
+ error
4103
+ });
4104
+ }
3578
4105
  } catch (error) {
3579
4106
  span.setStatus({
3580
4107
  code: _opentelemetry_api.SpanStatusCode.ERROR,
@@ -3589,18 +4116,65 @@ var FederationImpl = class extends FederationBuilderImpl {
3589
4116
  return;
3590
4117
  }
3591
4118
  })();
4119
+ let retryAfterDelay;
4120
+ let circuitHold;
4121
+ let circuitDrop;
4122
+ let retryPolicyDelay;
4123
+ let policyDelayCalculated = false;
4124
+ const getPolicyDelay = () => {
4125
+ if (!policyDelayCalculated) {
4126
+ retryPolicyDelay = this.outboxRetryPolicy({
4127
+ elapsedTime: Temporal.Instant.from(message.started).until(Temporal.Now.instant()),
4128
+ attempts: message.attempt
4129
+ });
4130
+ policyDelayCalculated = true;
4131
+ }
4132
+ return retryPolicyDelay;
4133
+ };
4134
+ const isPermanentFailure = error instanceof SendActivityError && this.permanentFailureStatusCodes.includes(error.statusCode);
4135
+ if (!isPermanentFailure && error instanceof SendActivityError && (error.statusCode === 429 || error.statusCode === 503)) retryAfterDelay = parseRetryAfter(error.responseHeaders);
4136
+ if (remoteHost != null && this.outboxQueue != null && this.circuitBreaker != null) try {
4137
+ if (error instanceof SendActivityError) {
4138
+ const { statusCode } = error;
4139
+ const stateChange = isPermanentFailure || statusCode === 429 || statusCode >= 400 && statusCode < 500 ? await this.circuitBreaker.recordReachableFailure(remoteHost) : statusCode >= 500 ? await this.circuitBreaker.recordFailure(remoteHost) : void 0;
4140
+ if (stateChange != null) recordCircuitBreakerSpanEvent(span, remoteHost, stateChange);
4141
+ } else if (isTransportDeliveryError(error)) {
4142
+ const stateChange = await this.circuitBreaker.recordFailure(remoteHost);
4143
+ if (stateChange != null) recordCircuitBreakerSpanEvent(span, remoteHost, stateChange);
4144
+ }
4145
+ if (!isPermanentFailure) {
4146
+ const circuitDecision = await this.circuitBreaker.beforeSend(remoteHost, message);
4147
+ if (circuitDecision.type === "hold") circuitHold = {
4148
+ delay: circuitDecision.delay,
4149
+ heldSince: circuitDecision.heldSince,
4150
+ remoteHost,
4151
+ state: circuitDecision.state
4152
+ };
4153
+ else if (circuitDecision.type === "drop") circuitDrop = {
4154
+ circuit: this.circuitBreaker,
4155
+ remoteHost,
4156
+ inbox: new URL(message.inbox),
4157
+ heldSince: circuitDecision.heldSince
4158
+ };
4159
+ }
4160
+ } catch (circuitError) {
4161
+ (0, _logtape_logtape.getLogger)([
4162
+ "fedify",
4163
+ "federation",
4164
+ "circuit"
4165
+ ]).error("Failed to update circuit breaker state after delivery failure; falling back to normal failure handling:\n{error}", {
4166
+ ...logData,
4167
+ remoteHost,
4168
+ error: circuitError
4169
+ });
4170
+ }
3592
4171
  span.addEvent("activitypub.delivery.failed", {
3593
4172
  ...remoteHost == null ? {} : { "activitypub.remote.host": remoteHost },
3594
4173
  "activitypub.delivery.attempt": message.attempt,
3595
- "activitypub.delivery.permanent_failure": error instanceof SendActivityError && this.permanentFailureStatusCodes.includes(error.statusCode),
4174
+ "activitypub.delivery.permanent_failure": isPermanentFailure,
3596
4175
  ...error instanceof SendActivityError ? { "http.response.status_code": error.statusCode } : {}
3597
4176
  });
3598
- const loaderOptions = this.#getLoaderOptions(message.baseUrl);
3599
- const activity = await _fedify_vocab.Activity.fromJsonLd(message.activity, {
3600
- contextLoader: this.contextLoaderFactory(loaderOptions),
3601
- documentLoader: rsaKeyPair == null ? this.documentLoaderFactory(loaderOptions) : this.authenticatedDocumentLoaderFactory(rsaKeyPair, loaderOptions),
3602
- tracerProvider: this.tracerProvider
3603
- });
4177
+ const activity = await parseActivity();
3604
4178
  try {
3605
4179
  await this.onOutboxError?.(error, activity);
3606
4180
  } catch (error) {
@@ -3609,7 +4183,11 @@ var FederationImpl = class extends FederationBuilderImpl {
3609
4183
  error
3610
4184
  });
3611
4185
  }
3612
- if (error instanceof SendActivityError && this.permanentFailureStatusCodes.includes(error.statusCode)) {
4186
+ if (circuitDrop != null) {
4187
+ await dropHeldOutboxMessage(circuitDrop.circuit, circuitDrop.remoteHost, circuitDrop.inbox, circuitDrop.heldSince, activity);
4188
+ return;
4189
+ }
4190
+ if (isPermanentFailure) {
3613
4191
  require_http.getFederationMetrics(this.meterProvider).recordPermanentFailure(error.inbox, error.statusCode);
3614
4192
  logger.warn("Permanent delivery failure for activity {activityId} to {inbox} ({status}); not retrying.", {
3615
4193
  ...logData,
@@ -3619,18 +4197,12 @@ var FederationImpl = class extends FederationBuilderImpl {
3619
4197
  const ctx = this.#createContext(new URL(message.baseUrl), _, { documentLoader: this.documentLoaderFactory(loaderOptions) });
3620
4198
  try {
3621
4199
  await this.outboxPermanentFailureHandler(ctx, {
4200
+ reason: "http",
3622
4201
  inbox: new URL(message.inbox),
3623
4202
  activity,
3624
4203
  error,
3625
4204
  statusCode: error.statusCode,
3626
- actorIds: (message.actorIds ?? []).flatMap((id) => {
3627
- try {
3628
- return [new URL(id)];
3629
- } catch {
3630
- logger.warn("Invalid actorId URL in OutboxMessage: {id}", { id });
3631
- return [];
3632
- }
3633
- })
4205
+ actorIds: getActorIds()
3634
4206
  });
3635
4207
  } catch (handlerError) {
3636
4208
  logger.error("An unexpected error occurred in outboxPermanentFailureHandler:\n{error}", {
@@ -3642,17 +4214,25 @@ var FederationImpl = class extends FederationBuilderImpl {
3642
4214
  require_http.recordOutboxActivity(this.meterProvider, "abandoned", message.activityType);
3643
4215
  return;
3644
4216
  }
3645
- if (this.outboxQueue?.nativeRetrial) {
4217
+ if (circuitHold != null && getPolicyDelay() != null) {
4218
+ logger.error("Failed to send activity {activityId} to {inbox}; holding because the remote host circuit is open:\n{error}", {
4219
+ ...logData,
4220
+ error
4221
+ });
4222
+ recordCircuitBreakerHeldSpanEvent(span, circuitHold.remoteHost, circuitHold.state);
4223
+ const circuit = this.circuitBreaker;
4224
+ await enqueueHeldOutboxMessage(retryAfterDelay == null || circuit == null ? circuitHold.delay : circuit.capHeldDelay(circuitHold.heldSince, maxDelay(circuitHold.delay, retryAfterDelay)), circuitHold.heldSince);
4225
+ return;
4226
+ }
4227
+ if (this.outboxQueue?.nativeRetrial && retryAfterDelay == null) {
3646
4228
  logger.error("Failed to send activity {activityId} to {inbox}; backend will handle retry:\n{error}", {
3647
4229
  ...logData,
3648
4230
  error
3649
4231
  });
3650
4232
  throw error;
3651
4233
  }
3652
- const delay = this.outboxRetryPolicy({
3653
- elapsedTime: Temporal.Instant.from(message.started).until(Temporal.Now.instant()),
3654
- attempts: message.attempt
3655
- });
4234
+ const policyDelay = getPolicyDelay();
4235
+ const delay = policyDelay == null ? null : retryAfterDelay ?? policyDelay;
3656
4236
  if (delay != null) {
3657
4237
  logger.error("Failed to send activity {activityId} to {inbox} (attempt #{attempt}); retry...:\n{error}", {
3658
4238
  ...logData,
@@ -3664,7 +4244,10 @@ var FederationImpl = class extends FederationBuilderImpl {
3664
4244
  };
3665
4245
  const { outboxQueue } = this;
3666
4246
  if (outboxQueue != null) {
3667
- await outboxQueue.enqueue(retryMessage, { delay: Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay });
4247
+ await outboxQueue.enqueue(retryMessage, {
4248
+ delay: clampNegativeDelay(delay),
4249
+ orderingKey: message.orderingKey
4250
+ });
3668
4251
  require_http.getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
3669
4252
  role: "outbox",
3670
4253
  queue: outboxQueue,
@@ -3753,7 +4336,7 @@ var FederationImpl = class extends FederationBuilderImpl {
3753
4336
  ...message,
3754
4337
  attempt: message.attempt + 1
3755
4338
  };
3756
- await this.inboxQueue.enqueue(retryMessage, { delay: Temporal.Duration.compare(delay, { seconds: 0 }) < 0 ? Temporal.Duration.from({ seconds: 0 }) : delay });
4339
+ await this.inboxQueue.enqueue(retryMessage, { delay: clampNegativeDelay(delay) });
3757
4340
  if (activityType != null) {
3758
4341
  require_http.getFederationMetrics(this.meterProvider).recordQueueTaskEnqueued({
3759
4342
  role: "inbox",
@@ -5506,6 +6089,12 @@ function getRequestId(request) {
5506
6089
  return `req_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
5507
6090
  }
5508
6091
  //#endregion
6092
+ Object.defineProperty(exports, "CircuitBreaker", {
6093
+ enumerable: true,
6094
+ get: function() {
6095
+ return CircuitBreaker;
6096
+ }
6097
+ });
5509
6098
  Object.defineProperty(exports, "SendActivityError", {
5510
6099
  enumerable: true,
5511
6100
  get: function() {
@@ -5554,6 +6143,18 @@ Object.defineProperty(exports, "middleware_exports", {
5554
6143
  return middleware_exports;
5555
6144
  }
5556
6145
  });
6146
+ Object.defineProperty(exports, "normalizeCircuitBreakerOptions", {
6147
+ enumerable: true,
6148
+ get: function() {
6149
+ return normalizeCircuitBreakerOptions;
6150
+ }
6151
+ });
6152
+ Object.defineProperty(exports, "parseCircuitBreakerKvState", {
6153
+ enumerable: true,
6154
+ get: function() {
6155
+ return parseCircuitBreakerKvState;
6156
+ }
6157
+ });
5557
6158
  Object.defineProperty(exports, "respondWithObject", {
5558
6159
  enumerable: true,
5559
6160
  get: function() {