@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.
@@ -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 retryEnabled = Boolean(
910
- retryOptions &&
911
- retryOptions.attempts > 1 &&
912
- isProxyTransportRetryMethod(methodName, retryOptions),
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
- const attemptOptions: RequestOptions & { body?: unknown } = statusRetryEnabled
922
- ? { ...headersOptions, throwOnHttpError: false }
923
- : headersOptions;
924
-
925
- // Span the whole vendor chain on transport failures. Like ctx.stealth, a
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
- const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
976
- fetchNativeHttp(
977
- baseUrl,
978
- url,
979
- methodName,
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
- if (!retryEnabled || !retryOptions) {
989
- throwIfAmbientAborted(clientOptions.signal);
990
- const outcome = await executeOnce();
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
- // Duplicate endpoint from a partial allocation: advance the flat
1024
- // offset without issuing the request (no backoff, not a failure) so
1025
- // the loop keeps rotating toward the fallback vendor.
1026
- continue;
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
- lastStatus = outcome.status;
1032
- if (outcome.retryable && issued < retryOptions.attempts) {
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, outcome.headers),
1170
+ computeProxyTransportRetryDelayMs(retryOptions, attempt),
1035
1171
  clientOptions.signal,
1036
1172
  );
1037
1173
  continue;
1038
1174
  }
1039
- throw toUpstreamHttpError(outcome.status);
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
- // Reached when the attempt cap is consumed without a terminal outcome —
1087
- // e.g. the final offsets of a partial allocation all resolved to
1088
- // already-tried endpoints and were skipped. Surface the last real transport
1089
- // failure rather than a synthetic exhaustion error.
1090
- if (lastError !== undefined) {
1091
- throw lastError;
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
- return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
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 {
@@ -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,