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