@apifuse/provider-sdk 2.2.0-beta.61 → 2.2.0-beta.62
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/CHANGELOG.md +4 -0
- package/bin/apifuse-pack-types.ts +16 -12
- package/dist/handle-meta.d.ts +18 -2
- package/dist/handle-meta.js +30 -2
- package/dist/handle.d.ts +33 -12
- package/dist/handle.js +69 -24
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lint.js +28 -8
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +2 -0
- package/dist/runtime/handle.js +15 -10
- package/dist/runtime/http-telemetry-binding.d.ts +10 -0
- package/dist/runtime/http-telemetry-binding.js +152 -0
- package/dist/runtime/http-telemetry-guard.d.ts +6 -0
- package/dist/runtime/http-telemetry-guard.js +150 -0
- package/dist/runtime/http-telemetry.d.ts +104 -0
- package/dist/runtime/http-telemetry.js +190 -0
- package/dist/runtime/http.d.ts +31 -0
- package/dist/runtime/http.js +222 -146
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +1 -0
- package/dist/server/serve-implementation.d.ts +2 -0
- package/dist/server/serve-implementation.js +34 -35
- package/package.json +1 -1
- package/src/handle-meta.ts +39 -4
- package/src/handle.ts +121 -38
- package/src/index.ts +19 -0
- package/src/lint.ts +30 -7
- package/src/provider.ts +10 -0
- package/src/runtime/handle.ts +20 -10
- package/src/runtime/http-telemetry-binding.ts +162 -0
- package/src/runtime/http-telemetry-guard.ts +155 -0
- package/src/runtime/http-telemetry.ts +292 -0
- package/src/runtime/http.ts +295 -173
- package/src/server/index.ts +13 -0
- package/src/server/serve-implementation.ts +41 -58
package/src/runtime/http.ts
CHANGED
|
@@ -29,6 +29,11 @@ import {
|
|
|
29
29
|
shouldRetryProxyTransportAttempt,
|
|
30
30
|
validateUnsafeProxyTransportRetryMethods,
|
|
31
31
|
} from "./proxy-retry-policy.js";
|
|
32
|
+
import {
|
|
33
|
+
httpTelemetryErrorCode,
|
|
34
|
+
type HttpTelemetrySink,
|
|
35
|
+
type HttpAttemptTelemetryEvent,
|
|
36
|
+
} from "./http-telemetry.js";
|
|
32
37
|
import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
|
|
33
38
|
import {
|
|
34
39
|
normalizeHttpRequestBody,
|
|
@@ -38,12 +43,70 @@ import {
|
|
|
38
43
|
serializeRequestUrl,
|
|
39
44
|
} from "./request-options.js";
|
|
40
45
|
|
|
46
|
+
import { startHttpTelemetry } from "./http-telemetry-guard.js";
|
|
47
|
+
import { scopedHttpTelemetry } from "./http-telemetry-binding.js";
|
|
48
|
+
|
|
41
49
|
const DEFAULT_HTTP_BASE_URL = "http://localhost";
|
|
42
50
|
|
|
51
|
+
function httpAttemptFailure(
|
|
52
|
+
error: unknown,
|
|
53
|
+
status?: number,
|
|
54
|
+
): Omit<HttpAttemptTelemetryEvent, "ms" | "proxyUsed"> {
|
|
55
|
+
try {
|
|
56
|
+
return {
|
|
57
|
+
e: httpTelemetryErrorCode(proxyTransportRetryErrorCode(error)),
|
|
58
|
+
status: proxyTransportRetryErrorStatus(error) ?? status,
|
|
59
|
+
...(error instanceof Error
|
|
60
|
+
? {
|
|
61
|
+
diagnostics: {
|
|
62
|
+
name: error.name,
|
|
63
|
+
message: error.message,
|
|
64
|
+
...(error.cause instanceof Error
|
|
65
|
+
? { cause: { name: error.cause.name, message: error.cause.message } }
|
|
66
|
+
: {}),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return { e: "other" };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
43
76
|
export type HttpClientOptions = ProxyResolutionOptions & {
|
|
44
77
|
warn?: (message: string) => void;
|
|
45
78
|
userAgent?: string;
|
|
46
79
|
onRetrySummary?: (summary: HttpRetrySummary) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Synchronous observer for HTTP attempts, retries, timeouts, status, duration,
|
|
82
|
+
* and observed proxy use. Observer failures never change transport behavior.
|
|
83
|
+
* All hooks and their property reads are guarded, including `onRetrySummary`
|
|
84
|
+
* and the failure reporter. Invalid asynchronous results are ignored and never
|
|
85
|
+
* awaited. Observer failures set the log-only `telemetryFailed` marker; the
|
|
86
|
+
* marker and redacted error diagnostics are excluded from headers and tenant
|
|
87
|
+
* metadata. Tenant `meta.retry` retains its existing response shape.
|
|
88
|
+
*
|
|
89
|
+
* After `ProviderEngine.attach`, the server wraps supported host HTTP bindings
|
|
90
|
+
* with the request collector. The adapter preserves arguments, direct-call,
|
|
91
|
+
* explicit `.call`/`.apply`, and unbound receivers, plus the original returned
|
|
92
|
+
* promise, response, stream, and error. Streams and SSE are observed at headers
|
|
93
|
+
* without consuming their bodies. SDK clients report every observed attempt
|
|
94
|
+
* and keep any observer configured here; routing is scoped to each call.
|
|
95
|
+
*
|
|
96
|
+
* Binding an existing adapter reuses its identity and replaces its collector:
|
|
97
|
+
* the latest bind receives subsequent calls, the earlier collector receives no
|
|
98
|
+
* further calls, and in-flight calls keep the collector captured at invocation.
|
|
99
|
+
* An opaque host invocation is one observed attempt with zero observed retries;
|
|
100
|
+
* its undisclosed retries and proxy settings cannot be inferred, and tenant
|
|
101
|
+
* `meta.retry` is omitted. Status, duration, and error class remain available
|
|
102
|
+
* in logs and telemetry headers.
|
|
103
|
+
*
|
|
104
|
+
* Non-object bindings and objects without a callable `request`, `get`, `post`,
|
|
105
|
+
* `put`, `delete`, `stream`, or `sse` method are left untouched, with no `http`
|
|
106
|
+
* telemetry sibling and one process-wide warning for this reason:
|
|
107
|
+
* `[apifuse] http telemetry not attached; reason=unsupported_binding_shape`.
|
|
108
|
+
*/
|
|
109
|
+
httpTelemetry?: HttpTelemetrySink;
|
|
47
110
|
signal?: AbortSignal;
|
|
48
111
|
};
|
|
49
112
|
|
|
@@ -615,7 +678,7 @@ async function fetchWithHttpRedirectPolicy(
|
|
|
615
678
|
return `Redirect loop refused target ${target}`;
|
|
616
679
|
case "missing_location":
|
|
617
680
|
return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
|
|
618
|
-
|
|
681
|
+
}
|
|
619
682
|
})();
|
|
620
683
|
throw new HttpRedirectError(message, {
|
|
621
684
|
reason: decision.reason,
|
|
@@ -709,6 +772,7 @@ async function fetchNativeHttp(
|
|
|
709
772
|
statusRetryCodes?: readonly number[],
|
|
710
773
|
proxyAttemptOffset = 0,
|
|
711
774
|
dedupe?: { attempted: Set<string> },
|
|
775
|
+
observeProxy?: (used: boolean, status?: number) => void,
|
|
712
776
|
): Promise<NativeHttpAttemptOutcome> {
|
|
713
777
|
const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
|
|
714
778
|
const { requestUrl } = serializedUrl;
|
|
@@ -725,6 +789,7 @@ async function fetchNativeHttp(
|
|
|
725
789
|
// failures are branded as TransportErrors and count against the request
|
|
726
790
|
// deadline, exactly as an inline resolve would.
|
|
727
791
|
proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
|
|
792
|
+
observeProxy?.(Boolean(proxy));
|
|
728
793
|
throwIfAmbientAborted(clientOptions.signal);
|
|
729
794
|
// For a registry allocator chain, skip an endpoint a prior attempt already
|
|
730
795
|
// tried rather than re-issuing the same request. Returning the sentinel
|
|
@@ -750,6 +815,7 @@ async function fetchNativeHttp(
|
|
|
750
815
|
requestInit,
|
|
751
816
|
options.redirectPolicy,
|
|
752
817
|
);
|
|
818
|
+
observeProxy?.(Boolean(proxy), response.status);
|
|
753
819
|
const headers = Object.fromEntries(response.headers.entries());
|
|
754
820
|
|
|
755
821
|
if (statusRetryCodes && response.status >= 400) {
|
|
@@ -801,6 +867,7 @@ async function fetchNativeHttpStream(
|
|
|
801
867
|
options: RequestOptions & { body?: unknown },
|
|
802
868
|
clientOptions: HttpClientOptions,
|
|
803
869
|
warn: (message: string) => void,
|
|
870
|
+
observeProxy?: (used: boolean) => void,
|
|
804
871
|
): Promise<HttpStreamResponse> {
|
|
805
872
|
const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
|
|
806
873
|
const { requestUrl } = serializedUrl;
|
|
@@ -813,6 +880,7 @@ async function fetchNativeHttpStream(
|
|
|
813
880
|
try {
|
|
814
881
|
throwIfAmbientAborted(clientOptions.signal);
|
|
815
882
|
const proxy = await resolveNativeProxy(options, clientOptions, warn);
|
|
883
|
+
observeProxy?.(Boolean(proxy));
|
|
816
884
|
throwIfAmbientAborted(clientOptions.signal);
|
|
817
885
|
const requestInit: NativeFetchInit = {
|
|
818
886
|
headers: options.headers,
|
|
@@ -906,193 +974,219 @@ export function createHttpClient(
|
|
|
906
974
|
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
907
975
|
}
|
|
908
976
|
})();
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
);
|
|
914
|
-
const statusRetryEnabled = Boolean(
|
|
915
|
-
retryEnabled &&
|
|
916
|
-
explicitRetry &&
|
|
917
|
-
retryOptions &&
|
|
918
|
-
retryOptions.statusCodes.length > 0 &&
|
|
919
|
-
headersOptions.throwOnHttpError !== false,
|
|
977
|
+
const requestStarted = performance.now();
|
|
978
|
+
const telemetry = startHttpTelemetry(
|
|
979
|
+
scopedHttpTelemetry(clientOptions.httpTelemetry),
|
|
980
|
+
retryOptions?.preset,
|
|
920
981
|
);
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
// policy-managed proxy resolves a *different* endpoint/vendor per attempt
|
|
927
|
-
// (the flat proxyAttemptOffset rotates across the concatenated vendor pool
|
|
928
|
-
// spans), so a transport failure should advance to the next endpoint —
|
|
929
|
-
// potentially crossing into the fallback vendor — rather than stopping at
|
|
930
|
-
// the per-endpoint retry budget and stranding the request on the primary
|
|
931
|
-
// vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
|
|
932
|
-
// only for implicit, safe-method allocator requests; explicit retry
|
|
933
|
-
// policies (their documented `attempts` ceiling), unsafe methods, and
|
|
934
|
-
// static/non-registry vendors keep the retry budget. Status-code retries
|
|
935
|
-
// stay bounded by the retry budget regardless; only transport rotation gets
|
|
936
|
-
// the full span.
|
|
937
|
-
const policyProxy: ProviderProxyPolicy | undefined = (() => {
|
|
938
|
-
const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
|
|
939
|
-
return policy && typeof policy === "object" ? policy : undefined;
|
|
940
|
-
})();
|
|
941
|
-
const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
|
|
942
|
-
const transportAttemptCap = retryOptions
|
|
943
|
-
? resolvePolicyTransportAttemptCap({
|
|
944
|
-
policy: policyProxy,
|
|
945
|
-
usesPolicyAllocator,
|
|
946
|
-
retryAttempts: retryOptions.attempts,
|
|
947
|
-
explicitRetry,
|
|
948
|
-
method: methodName,
|
|
949
|
-
})
|
|
950
|
-
: 1;
|
|
951
|
-
|
|
952
|
-
// Track resolved endpoints across a policy-allocator chain. Successive
|
|
953
|
-
// attempts rotate the flat offset across the concatenated vendor pool
|
|
954
|
-
// spans, but an under-filled allocation (fewer live endpoints than the
|
|
955
|
-
// configured pool size) makes the modulo mapping repeat endpoints before
|
|
956
|
-
// the offset reaches the next vendor. Rather than re-hammering an
|
|
957
|
-
// already-tried endpoint under backoff, fetchNativeHttp returns a skip
|
|
958
|
-
// sentinel for a duplicate; the loop then advances the flat offset without
|
|
959
|
-
// issuing the request, so it keeps walking toward — and into — the fallback
|
|
960
|
-
// vendor's pool span instead of stalling on the primary vendor.
|
|
961
|
-
// De-duplication is gated on the SAME predicate that widens the attempt cap
|
|
962
|
-
// (implicit, safe-method, registry-chain rotation). It must NOT engage for
|
|
963
|
-
// an explicit retry policy: there the caller's `attempts` count is the
|
|
964
|
-
// contract and each attempt must issue against whatever endpoint it resolves
|
|
965
|
-
// — even a repeat — instead of being silently skipped (which would collapse
|
|
966
|
-
// a `poolSize: 1` + `attempts: 3` request to a single fetch).
|
|
967
|
-
const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
|
|
968
|
-
policy: policyProxy,
|
|
969
|
-
usesPolicyAllocator,
|
|
970
|
-
explicitRetry,
|
|
971
|
-
method: methodName,
|
|
972
|
-
});
|
|
973
|
-
const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set<string>() } : undefined;
|
|
982
|
+
try {
|
|
983
|
+
return await runRequest();
|
|
984
|
+
} finally {
|
|
985
|
+
telemetry.finish(performance.now() - requestStarted);
|
|
986
|
+
}
|
|
974
987
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
attemptOptions,
|
|
981
|
-
clientOptions,
|
|
982
|
-
warnOnce,
|
|
983
|
-
statusRetryEnabled ? retryOptions?.statusCodes : undefined,
|
|
984
|
-
proxyAttemptOffset,
|
|
985
|
-
dedupeContext,
|
|
988
|
+
async function runRequest(): Promise<HttpResponse> {
|
|
989
|
+
const retryEnabled = Boolean(
|
|
990
|
+
retryOptions &&
|
|
991
|
+
retryOptions.attempts > 1 &&
|
|
992
|
+
isProxyTransportRetryMethod(methodName, retryOptions),
|
|
986
993
|
);
|
|
994
|
+
const statusRetryEnabled = Boolean(
|
|
995
|
+
retryEnabled &&
|
|
996
|
+
explicitRetry &&
|
|
997
|
+
retryOptions &&
|
|
998
|
+
retryOptions.statusCodes.length > 0 &&
|
|
999
|
+
headersOptions.throwOnHttpError !== false,
|
|
1000
|
+
);
|
|
1001
|
+
const attemptOptions: RequestOptions & { body?: unknown } = statusRetryEnabled
|
|
1002
|
+
? { ...headersOptions, throwOnHttpError: false }
|
|
1003
|
+
: headersOptions;
|
|
1004
|
+
|
|
1005
|
+
// Span the whole vendor chain on transport failures. Like ctx.stealth, a
|
|
1006
|
+
// policy-managed proxy resolves a *different* endpoint/vendor per attempt
|
|
1007
|
+
// (the flat proxyAttemptOffset rotates across the concatenated vendor pool
|
|
1008
|
+
// spans), so a transport failure should advance to the next endpoint —
|
|
1009
|
+
// potentially crossing into the fallback vendor — rather than stopping at
|
|
1010
|
+
// the per-endpoint retry budget and stranding the request on the primary
|
|
1011
|
+
// vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
|
|
1012
|
+
// only for implicit, safe-method allocator requests; explicit retry
|
|
1013
|
+
// policies (their documented `attempts` ceiling), unsafe methods, and
|
|
1014
|
+
// static/non-registry vendors keep the retry budget. Status-code retries
|
|
1015
|
+
// stay bounded by the retry budget regardless; only transport rotation gets
|
|
1016
|
+
// the full span.
|
|
1017
|
+
const policyProxy: ProviderProxyPolicy | undefined = (() => {
|
|
1018
|
+
const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
|
|
1019
|
+
return policy && typeof policy === "object" ? policy : undefined;
|
|
1020
|
+
})();
|
|
1021
|
+
const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
|
|
1022
|
+
const transportAttemptCap = retryOptions
|
|
1023
|
+
? resolvePolicyTransportAttemptCap({
|
|
1024
|
+
policy: policyProxy,
|
|
1025
|
+
usesPolicyAllocator,
|
|
1026
|
+
retryAttempts: retryOptions.attempts,
|
|
1027
|
+
explicitRetry,
|
|
1028
|
+
method: methodName,
|
|
1029
|
+
})
|
|
1030
|
+
: 1;
|
|
1031
|
+
|
|
1032
|
+
// Track resolved endpoints across a policy-allocator chain. Successive
|
|
1033
|
+
// attempts rotate the flat offset across the concatenated vendor pool
|
|
1034
|
+
// spans, but an under-filled allocation (fewer live endpoints than the
|
|
1035
|
+
// configured pool size) makes the modulo mapping repeat endpoints before
|
|
1036
|
+
// the offset reaches the next vendor. Rather than re-hammering an
|
|
1037
|
+
// already-tried endpoint under backoff, fetchNativeHttp returns a skip
|
|
1038
|
+
// sentinel for a duplicate; the loop then advances the flat offset without
|
|
1039
|
+
// issuing the request, so it keeps walking toward — and into — the fallback
|
|
1040
|
+
// vendor's pool span instead of stalling on the primary vendor.
|
|
1041
|
+
// De-duplication is gated on the SAME predicate that widens the attempt cap
|
|
1042
|
+
// (implicit, safe-method, registry-chain rotation). It must NOT engage for
|
|
1043
|
+
// an explicit retry policy: there the caller's `attempts` count is the
|
|
1044
|
+
// contract and each attempt must issue against whatever endpoint it resolves
|
|
1045
|
+
// — even a repeat — instead of being silently skipped (which would collapse
|
|
1046
|
+
// a `poolSize: 1` + `attempts: 3` request to a single fetch).
|
|
1047
|
+
const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
|
|
1048
|
+
policy: policyProxy,
|
|
1049
|
+
usesPolicyAllocator,
|
|
1050
|
+
explicitRetry,
|
|
1051
|
+
method: methodName,
|
|
1052
|
+
});
|
|
1053
|
+
const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set<string>() } : undefined;
|
|
1054
|
+
|
|
1055
|
+
const executeOnce = async (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> => {
|
|
1056
|
+
const started = performance.now();
|
|
1057
|
+
let proxyUsed = false;
|
|
1058
|
+
let observedStatus: number | undefined;
|
|
1059
|
+
const record = (event: Omit<HttpAttemptTelemetryEvent, "ms" | "proxyUsed">) => {
|
|
1060
|
+
telemetry.recordAttempt({ ...event, ms: performance.now() - started, proxyUsed });
|
|
1061
|
+
};
|
|
1062
|
+
try {
|
|
1063
|
+
const outcome = await fetchNativeHttp(
|
|
1064
|
+
baseUrl,
|
|
1065
|
+
url,
|
|
1066
|
+
methodName,
|
|
1067
|
+
attemptOptions,
|
|
1068
|
+
clientOptions,
|
|
1069
|
+
warnOnce,
|
|
1070
|
+
statusRetryEnabled ? retryOptions?.statusCodes : undefined,
|
|
1071
|
+
proxyAttemptOffset,
|
|
1072
|
+
dedupeContext,
|
|
1073
|
+
(used, status) => {
|
|
1074
|
+
proxyUsed = used;
|
|
1075
|
+
observedStatus = status;
|
|
1076
|
+
},
|
|
1077
|
+
);
|
|
1078
|
+
if (!isDedupeSkipOutcome(outcome))
|
|
1079
|
+
record({
|
|
1080
|
+
status: outcome.status,
|
|
1081
|
+
...(isHttpStatusOutcome(outcome)
|
|
1082
|
+
? { e: "upstream_http_error", statusRetry: true }
|
|
1083
|
+
: {}),
|
|
1084
|
+
});
|
|
1085
|
+
return outcome;
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
// Record before the retry loop can catch/rethrow a terminal status.
|
|
1088
|
+
record(httpAttemptFailure(error, observedStatus));
|
|
1089
|
+
throw error;
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
987
1092
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
if (isDedupeSkipOutcome(outcome)) {
|
|
992
|
-
// Single-shot path never de-duplicates (dedupeContext is undefined),
|
|
993
|
-
// but keep the union total.
|
|
994
|
-
throw new TransportError("HTTP request produced no terminal result", {
|
|
995
|
-
code: "retry_exhausted",
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
if (isHttpStatusOutcome(outcome)) {
|
|
999
|
-
throw toUpstreamHttpError(outcome.status);
|
|
1000
|
-
}
|
|
1001
|
-
return outcome;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
let lastError: unknown;
|
|
1005
|
-
let lastErrorCode: string | undefined;
|
|
1006
|
-
let lastStatus: number | undefined;
|
|
1007
|
-
// `attempt` walks the flat proxy offset across the full chain span; `issued`
|
|
1008
|
-
// counts requests that were actually sent (skipped duplicate offsets do not
|
|
1009
|
-
// increment it). Retry summaries and the status-retry budget must reflect
|
|
1010
|
-
// issued requests, not the raw offset, so they stay accurate when partial
|
|
1011
|
-
// allocations skip offsets.
|
|
1012
|
-
let issued = 0;
|
|
1013
|
-
for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
|
|
1014
|
-
throwIfAmbientAborted(clientOptions.signal);
|
|
1015
|
-
// Whether this offset actually issued a request (vs. a skipped duplicate),
|
|
1016
|
-
// so the catch counts a thrown *transport* failure once without
|
|
1017
|
-
// double-counting a status outcome that already incremented before it
|
|
1018
|
-
// re-threw as an upstream HTTP error.
|
|
1019
|
-
let issuedThisAttempt = false;
|
|
1020
|
-
try {
|
|
1021
|
-
const outcome = await executeOnce(attempt - 1);
|
|
1093
|
+
if (!retryEnabled || !retryOptions) {
|
|
1094
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
1095
|
+
const outcome = await executeOnce();
|
|
1022
1096
|
if (isDedupeSkipOutcome(outcome)) {
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
|
|
1026
|
-
|
|
1097
|
+
// Single-shot path never de-duplicates (dedupeContext is undefined),
|
|
1098
|
+
// but keep the union total.
|
|
1099
|
+
throw new TransportError("HTTP request produced no terminal result", {
|
|
1100
|
+
code: "retry_exhausted",
|
|
1101
|
+
});
|
|
1027
1102
|
}
|
|
1028
|
-
issued += 1;
|
|
1029
|
-
issuedThisAttempt = true;
|
|
1030
1103
|
if (isHttpStatusOutcome(outcome)) {
|
|
1031
|
-
|
|
1032
|
-
|
|
1104
|
+
throw toUpstreamHttpError(outcome.status);
|
|
1105
|
+
}
|
|
1106
|
+
return outcome;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
let lastError: unknown;
|
|
1110
|
+
// `attempt` walks the flat proxy offset across the full chain span; `issued`
|
|
1111
|
+
// counts requests that were actually sent (skipped duplicate offsets do not
|
|
1112
|
+
// increment it). Retry summaries and the status-retry budget must reflect
|
|
1113
|
+
// issued requests, not the raw offset, so they stay accurate when partial
|
|
1114
|
+
// allocations skip offsets.
|
|
1115
|
+
let issued = 0;
|
|
1116
|
+
for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
|
|
1117
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
1118
|
+
// Whether this offset actually issued a request (vs. a skipped duplicate),
|
|
1119
|
+
// so the catch counts a thrown *transport* failure once without
|
|
1120
|
+
// double-counting a status outcome that already incremented before it
|
|
1121
|
+
// re-threw as an upstream HTTP error.
|
|
1122
|
+
let issuedThisAttempt = false;
|
|
1123
|
+
try {
|
|
1124
|
+
const outcome = await executeOnce(attempt - 1);
|
|
1125
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
1126
|
+
// Duplicate endpoint from a partial allocation: advance the flat
|
|
1127
|
+
// offset without issuing the request (no backoff, not a failure) so
|
|
1128
|
+
// the loop keeps rotating toward the fallback vendor.
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
issued += 1;
|
|
1132
|
+
issuedThisAttempt = true;
|
|
1133
|
+
if (isHttpStatusOutcome(outcome)) {
|
|
1134
|
+
if (outcome.retryable && issued < retryOptions.attempts) {
|
|
1135
|
+
await sleep(
|
|
1136
|
+
computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers),
|
|
1137
|
+
clientOptions.signal,
|
|
1138
|
+
);
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
throw toUpstreamHttpError(outcome.status);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const response = outcome;
|
|
1145
|
+
if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
|
|
1146
|
+
throw toUpstreamHttpError(response.status);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (issued > 1) {
|
|
1150
|
+
const summary = telemetry.toTenantRetryPayload();
|
|
1151
|
+
if (summary) telemetry.observe(() => clientOptions.onRetrySummary?.(summary));
|
|
1152
|
+
}
|
|
1153
|
+
return response;
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
throwIfAmbientAborted(clientOptions.signal);
|
|
1156
|
+
if (!issuedThisAttempt) issued += 1;
|
|
1157
|
+
lastError = error;
|
|
1158
|
+
const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
|
|
1159
|
+
if (
|
|
1160
|
+
attempt < transportAttemptCap &&
|
|
1161
|
+
shouldRetryProxyTransportAttempt({
|
|
1162
|
+
error,
|
|
1163
|
+
explicitRetry,
|
|
1164
|
+
method: methodName,
|
|
1165
|
+
options: retryOptions,
|
|
1166
|
+
proxyUsed,
|
|
1167
|
+
})
|
|
1168
|
+
) {
|
|
1033
1169
|
await sleep(
|
|
1034
|
-
computeProxyTransportRetryDelayMs(retryOptions, attempt
|
|
1170
|
+
computeProxyTransportRetryDelayMs(retryOptions, attempt),
|
|
1035
1171
|
clientOptions.signal,
|
|
1036
1172
|
);
|
|
1037
1173
|
continue;
|
|
1038
1174
|
}
|
|
1039
|
-
throw
|
|
1175
|
+
throw error;
|
|
1040
1176
|
}
|
|
1041
|
-
|
|
1042
|
-
const response = outcome;
|
|
1043
|
-
if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
|
|
1044
|
-
throw toUpstreamHttpError(response.status);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
if (issued > 1) {
|
|
1048
|
-
const summary: HttpRetrySummary = {
|
|
1049
|
-
attempts: issued,
|
|
1050
|
-
retries: issued - 1,
|
|
1051
|
-
...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
|
|
1052
|
-
transport: "native",
|
|
1053
|
-
...(lastErrorCode ? { lastErrorCode } : {}),
|
|
1054
|
-
...(lastStatus ? { lastStatus } : {}),
|
|
1055
|
-
};
|
|
1056
|
-
clientOptions.onRetrySummary?.(summary);
|
|
1057
|
-
}
|
|
1058
|
-
return response;
|
|
1059
|
-
} catch (error) {
|
|
1060
|
-
throwIfAmbientAborted(clientOptions.signal);
|
|
1061
|
-
if (!issuedThisAttempt) issued += 1;
|
|
1062
|
-
lastError = error;
|
|
1063
|
-
lastErrorCode = proxyTransportRetryErrorCode(error);
|
|
1064
|
-
lastStatus = proxyTransportRetryErrorStatus(error);
|
|
1065
|
-
const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
|
|
1066
|
-
if (
|
|
1067
|
-
attempt < transportAttemptCap &&
|
|
1068
|
-
shouldRetryProxyTransportAttempt({
|
|
1069
|
-
error,
|
|
1070
|
-
explicitRetry,
|
|
1071
|
-
method: methodName,
|
|
1072
|
-
options: retryOptions,
|
|
1073
|
-
proxyUsed,
|
|
1074
|
-
})
|
|
1075
|
-
) {
|
|
1076
|
-
await sleep(
|
|
1077
|
-
computeProxyTransportRetryDelayMs(retryOptions, attempt),
|
|
1078
|
-
clientOptions.signal,
|
|
1079
|
-
);
|
|
1080
|
-
continue;
|
|
1081
|
-
}
|
|
1082
|
-
throw error;
|
|
1083
1177
|
}
|
|
1084
|
-
}
|
|
1085
1178
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1179
|
+
// Reached when the attempt cap is consumed without a terminal outcome —
|
|
1180
|
+
// e.g. the final offsets of a partial allocation all resolved to
|
|
1181
|
+
// already-tried endpoints and were skipped. Surface the last real transport
|
|
1182
|
+
// failure rather than a synthetic exhaustion error.
|
|
1183
|
+
if (lastError !== undefined) {
|
|
1184
|
+
throw lastError;
|
|
1185
|
+
}
|
|
1186
|
+
throw new TransportError("HTTP retry exhausted without a terminal result", {
|
|
1187
|
+
code: "retry_exhausted",
|
|
1188
|
+
});
|
|
1092
1189
|
}
|
|
1093
|
-
throw new TransportError("HTTP retry exhausted without a terminal result", {
|
|
1094
|
-
code: "retry_exhausted",
|
|
1095
|
-
});
|
|
1096
1190
|
}
|
|
1097
1191
|
|
|
1098
1192
|
async function streamRequest(
|
|
@@ -1121,7 +1215,35 @@ export function createHttpClient(
|
|
|
1121
1215
|
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
1122
1216
|
}
|
|
1123
1217
|
})();
|
|
1124
|
-
|
|
1218
|
+
// Streams never retry. Observe the header fetch without consuming its body.
|
|
1219
|
+
const telemetry = startHttpTelemetry(scopedHttpTelemetry(clientOptions.httpTelemetry), "off");
|
|
1220
|
+
const started = performance.now();
|
|
1221
|
+
const alreadyAborted = clientOptions.signal?.aborted === true;
|
|
1222
|
+
let proxyUsed = false;
|
|
1223
|
+
const record = (event: Omit<HttpAttemptTelemetryEvent, "ms" | "proxyUsed">) => {
|
|
1224
|
+
if (alreadyAborted) return;
|
|
1225
|
+
telemetry.recordAttempt({ ...event, ms: performance.now() - started, proxyUsed });
|
|
1226
|
+
};
|
|
1227
|
+
try {
|
|
1228
|
+
const response = await fetchNativeHttpStream(
|
|
1229
|
+
baseUrl,
|
|
1230
|
+
url,
|
|
1231
|
+
methodName,
|
|
1232
|
+
headersOptions,
|
|
1233
|
+
clientOptions,
|
|
1234
|
+
warnOnce,
|
|
1235
|
+
(used) => {
|
|
1236
|
+
proxyUsed = used;
|
|
1237
|
+
},
|
|
1238
|
+
);
|
|
1239
|
+
record({ status: response.status });
|
|
1240
|
+
return response;
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
record(httpAttemptFailure(error));
|
|
1243
|
+
throw error;
|
|
1244
|
+
} finally {
|
|
1245
|
+
telemetry.finish(performance.now() - started);
|
|
1246
|
+
}
|
|
1125
1247
|
}
|
|
1126
1248
|
|
|
1127
1249
|
return {
|
package/src/server/index.ts
CHANGED
|
@@ -129,6 +129,19 @@ export {
|
|
|
129
129
|
OperationSuccessResponseSchema,
|
|
130
130
|
} from "./types.js";
|
|
131
131
|
|
|
132
|
+
export {
|
|
133
|
+
HttpTelemetryCollector,
|
|
134
|
+
type HttpTelemetrySink,
|
|
135
|
+
type HttpTelemetryRequestSink,
|
|
136
|
+
type HttpAttemptTelemetryEvent,
|
|
137
|
+
type HttpAttemptSample,
|
|
138
|
+
type HttpTelemetryErrorCode,
|
|
139
|
+
type HttpTelemetryRetryPayload,
|
|
140
|
+
type HttpTelemetryLogPayload,
|
|
141
|
+
type HttpTelemetryHeaderPayload,
|
|
142
|
+
} from "../runtime/http-telemetry.js";
|
|
143
|
+
export type { HttpRetrySummary } from "../types.js";
|
|
144
|
+
|
|
132
145
|
export {
|
|
133
146
|
NativeTelemetryCollector,
|
|
134
147
|
type NativeAttemptSample,
|