@apifuse/provider-sdk 2.2.0-beta.61 → 2.2.0-beta.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/AUTHORING.md +268 -9
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +1 -0
  4. package/bin/apifuse-check.ts +84 -2
  5. package/bin/apifuse-dev.ts +11 -31
  6. package/bin/apifuse-migrate-deployment.ts +200 -0
  7. package/bin/apifuse-pack-check.ts +11 -4
  8. package/bin/apifuse-pack-types.ts +191 -4
  9. package/bin/apifuse-record.ts +44 -32
  10. package/bin/apifuse-test.ts +3 -1
  11. package/dist/cli/commands.d.ts +1 -1
  12. package/dist/cli/commands.js +11 -0
  13. package/dist/cli/deployment-intent.d.ts +178 -0
  14. package/dist/cli/deployment-intent.js +277 -0
  15. package/dist/cli/migrate-deployment-intent.d.ts +132 -0
  16. package/dist/cli/migrate-deployment-intent.js +1552 -0
  17. package/dist/cli/migrate-provider-shape.d.ts +5 -1
  18. package/dist/cli/migrate-provider-shape.js +4 -2
  19. package/dist/cli/resolver-runtime.d.ts +21 -0
  20. package/dist/cli/resolver-runtime.js +31 -0
  21. package/dist/define.d.ts +3 -0
  22. package/dist/define.js +5 -0
  23. package/dist/dev.d.ts +3 -1
  24. package/dist/dev.js +6 -1
  25. package/dist/engine.d.ts +35 -1
  26. package/dist/engine.js +21 -2
  27. package/dist/error-resolution.d.ts +1 -0
  28. package/dist/error-resolution.js +54 -0
  29. package/dist/errors.d.ts +39 -1
  30. package/dist/errors.js +50 -0
  31. package/dist/handle-meta.d.ts +18 -2
  32. package/dist/handle-meta.js +30 -2
  33. package/dist/handle.d.ts +33 -12
  34. package/dist/handle.js +69 -24
  35. package/dist/i18n/error-messages.d.ts +86 -0
  36. package/dist/i18n/error-messages.js +192 -0
  37. package/dist/i18n/index.d.ts +1 -0
  38. package/dist/i18n/index.js +1 -0
  39. package/dist/i18n/keys.d.ts +12 -0
  40. package/dist/i18n/keys.js +76 -5
  41. package/dist/index.d.ts +10 -2
  42. package/dist/index.js +9 -1
  43. package/dist/lint.d.ts +10 -0
  44. package/dist/lint.js +440 -57
  45. package/dist/provider.d.ts +7 -5
  46. package/dist/provider.js +4 -2
  47. package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
  48. package/dist/runtime/browser-telemetry-binding.js +83 -0
  49. package/dist/runtime/browser-telemetry.d.ts +76 -0
  50. package/dist/runtime/browser-telemetry.js +165 -0
  51. package/dist/runtime/browser.d.ts +3 -0
  52. package/dist/runtime/browser.js +208 -47
  53. package/dist/runtime/cache-telemetry.d.ts +71 -0
  54. package/dist/runtime/cache-telemetry.js +177 -0
  55. package/dist/runtime/cache.js +67 -7
  56. package/dist/runtime/capability-response.d.ts +3 -0
  57. package/dist/runtime/capability-response.js +56 -0
  58. package/dist/runtime/capability-telemetry.d.ts +46 -0
  59. package/dist/runtime/capability-telemetry.js +68 -0
  60. package/dist/runtime/diagnostic-env.d.ts +3 -0
  61. package/dist/runtime/diagnostic-env.js +10 -1
  62. package/dist/runtime/engine-mode.d.ts +74 -0
  63. package/dist/runtime/engine-mode.js +152 -0
  64. package/dist/runtime/handle.js +15 -10
  65. package/dist/runtime/http-telemetry-binding.d.ts +10 -0
  66. package/dist/runtime/http-telemetry-binding.js +152 -0
  67. package/dist/runtime/http-telemetry-guard.d.ts +16 -0
  68. package/dist/runtime/http-telemetry-guard.js +202 -0
  69. package/dist/runtime/http-telemetry.d.ts +104 -0
  70. package/dist/runtime/http-telemetry.js +190 -0
  71. package/dist/runtime/http.d.ts +31 -0
  72. package/dist/runtime/http.js +356 -174
  73. package/dist/runtime/instrumentation.js +92 -13
  74. package/dist/runtime/ocr-telemetry.d.ts +82 -0
  75. package/dist/runtime/ocr-telemetry.js +192 -0
  76. package/dist/runtime/ocr.js +30 -8
  77. package/dist/runtime/request-telemetry.d.ts +2 -0
  78. package/dist/runtime/request-telemetry.js +2 -1
  79. package/dist/runtime/resolver-runtime-options.d.ts +14 -0
  80. package/dist/runtime/resolver-runtime-options.js +33 -0
  81. package/dist/runtime/resolver-shared.d.ts +3 -2
  82. package/dist/runtime/resolver-shared.js +4 -4
  83. package/dist/runtime/resolver-telemetry.d.ts +1 -0
  84. package/dist/runtime/resolver-telemetry.js +1 -1
  85. package/dist/runtime/resolver-vendors/browser.d.ts +2 -0
  86. package/dist/runtime/resolver-vendors/browser.js +46 -0
  87. package/dist/runtime/resolver-vendors/hosts.d.ts +21 -0
  88. package/dist/runtime/resolver-vendors/hosts.js +35 -0
  89. package/dist/runtime/resolver-vendors/hypersolutions.js +2 -2
  90. package/dist/runtime/resolver-vendors/types.d.ts +5 -4
  91. package/dist/runtime/resolver.d.ts +4 -1
  92. package/dist/runtime/resolver.js +46 -13
  93. package/dist/runtime/state-telemetry.d.ts +79 -0
  94. package/dist/runtime/state-telemetry.js +194 -0
  95. package/dist/runtime/state.d.ts +3 -0
  96. package/dist/runtime/state.js +26 -7
  97. package/dist/runtime/stealth-cookies.d.ts +5 -1
  98. package/dist/runtime/stealth-cookies.js +21 -1
  99. package/dist/runtime/stealth-owned-headers.d.ts +26 -0
  100. package/dist/runtime/stealth-owned-headers.js +37 -0
  101. package/dist/runtime/stealth-telemetry.d.ts +92 -0
  102. package/dist/runtime/stealth-telemetry.js +179 -0
  103. package/dist/runtime/stealth.d.ts +4 -1
  104. package/dist/runtime/stealth.js +228 -85
  105. package/dist/runtime/stt-telemetry.d.ts +77 -0
  106. package/dist/runtime/stt-telemetry.js +176 -0
  107. package/dist/runtime/stt.js +15 -6
  108. package/dist/server/error-stack-frames.d.ts +35 -0
  109. package/dist/server/error-stack-frames.js +219 -0
  110. package/dist/server/index.d.ts +9 -0
  111. package/dist/server/index.js +5 -0
  112. package/dist/server/sdk-version.d.ts +3 -0
  113. package/dist/server/sdk-version.js +19 -0
  114. package/dist/server/self-test.js +1 -18
  115. package/dist/server/sensitive-values.d.ts +1 -1
  116. package/dist/server/sensitive-values.js +9 -0
  117. package/dist/server/serve-implementation.d.ts +53 -2
  118. package/dist/server/serve-implementation.js +369 -123
  119. package/dist/server/types.d.ts +1 -0
  120. package/dist/server/types.js +7 -0
  121. package/dist/stateful/stateful-provider-owner-forwarder.js +22 -0
  122. package/dist/stateful/stateful-provider-runtime-context.d.ts +3 -0
  123. package/dist/stateful/stateful-provider-runtime-context.js +1 -0
  124. package/dist/types.d.ts +94 -1
  125. package/package.json +6 -1
  126. package/src/cli/commands.ts +14 -0
  127. package/src/cli/deployment-intent.ts +364 -0
  128. package/src/cli/migrate-deployment-intent.ts +1908 -0
  129. package/src/cli/migrate-provider-shape.ts +10 -4
  130. package/src/cli/resolver-runtime.ts +72 -0
  131. package/src/define.ts +13 -0
  132. package/src/dev.ts +9 -2
  133. package/src/engine.ts +38 -2
  134. package/src/error-resolution.ts +58 -0
  135. package/src/errors.ts +77 -1
  136. package/src/handle-meta.ts +39 -4
  137. package/src/handle.ts +121 -38
  138. package/src/i18n/error-messages.ts +255 -0
  139. package/src/i18n/index.ts +1 -0
  140. package/src/i18n/keys.ts +80 -6
  141. package/src/index.ts +91 -0
  142. package/src/lint.ts +552 -57
  143. package/src/provider.ts +20 -0
  144. package/src/runtime/browser-telemetry-binding.ts +92 -0
  145. package/src/runtime/browser-telemetry.ts +289 -0
  146. package/src/runtime/browser.ts +249 -65
  147. package/src/runtime/cache-telemetry.ts +247 -0
  148. package/src/runtime/cache.ts +68 -17
  149. package/src/runtime/capability-response.ts +53 -0
  150. package/src/runtime/capability-telemetry.ts +101 -0
  151. package/src/runtime/diagnostic-env.ts +11 -1
  152. package/src/runtime/engine-mode.ts +233 -0
  153. package/src/runtime/handle.ts +20 -10
  154. package/src/runtime/http-telemetry-binding.ts +162 -0
  155. package/src/runtime/http-telemetry-guard.ts +208 -0
  156. package/src/runtime/http-telemetry.ts +292 -0
  157. package/src/runtime/http.ts +457 -204
  158. package/src/runtime/instrumentation.ts +105 -16
  159. package/src/runtime/ocr-telemetry.ts +308 -0
  160. package/src/runtime/ocr.ts +109 -74
  161. package/src/runtime/request-telemetry.ts +2 -1
  162. package/src/runtime/resolver-runtime-options.ts +50 -0
  163. package/src/runtime/resolver-shared.ts +9 -4
  164. package/src/runtime/resolver-telemetry.ts +1 -1
  165. package/src/runtime/resolver-vendors/browser.ts +65 -7
  166. package/src/runtime/resolver-vendors/hosts.ts +43 -0
  167. package/src/runtime/resolver-vendors/hypersolutions.ts +2 -2
  168. package/src/runtime/resolver-vendors/types.ts +5 -4
  169. package/src/runtime/resolver.ts +64 -21
  170. package/src/runtime/state-telemetry.ts +298 -0
  171. package/src/runtime/state.ts +46 -45
  172. package/src/runtime/stealth-cookies.ts +24 -1
  173. package/src/runtime/stealth-owned-headers.ts +41 -0
  174. package/src/runtime/stealth-telemetry.ts +304 -0
  175. package/src/runtime/stealth.ts +355 -150
  176. package/src/runtime/stt-telemetry.ts +282 -0
  177. package/src/runtime/stt.ts +93 -71
  178. package/src/server/error-stack-frames.ts +214 -0
  179. package/src/server/index.ts +66 -0
  180. package/src/server/sdk-version.ts +22 -0
  181. package/src/server/self-test.ts +1 -21
  182. package/src/server/sensitive-values.ts +14 -0
  183. package/src/server/serve-implementation.ts +601 -157
  184. package/src/server/types.ts +7 -0
  185. package/src/stateful/stateful-provider-owner-forwarder.ts +23 -0
  186. package/src/stateful/stateful-provider-runtime-context.ts +4 -0
  187. package/src/types.ts +99 -1
  188. package/src/cli/__tests__/fixtures/migrate-operation-declaration/approval-override.ts.txt +0 -6
  189. package/src/cli/__tests__/fixtures/migrate-operation-declaration/codemod-syntax.ts.txt +0 -3
  190. package/src/cli/__tests__/fixtures/migrate-operation-declaration/connection-precedence.ts.txt +0 -10
  191. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-a.ts.txt +0 -3
  192. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-b.ts.txt +0 -3
  193. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-barrel.ts.txt +0 -2
  194. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-index.ts.txt +0 -3
  195. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-computed.ts.txt +0 -6
  196. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-a.ts.txt +0 -1
  197. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-b.ts.txt +0 -1
  198. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-index.ts.txt +0 -3
  199. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-barrel.ts.txt +0 -7
  200. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-catalog.ts.txt +0 -19
  201. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-docs.ts.txt +0 -6
  202. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-index.ts.txt +0 -3
  203. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-stores.ts.txt +0 -8
  204. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-indirect-config.ts.txt +0 -5
  205. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-nonstatic.ts.txt +0 -4
  206. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-package-index.ts.txt +0 -3
  207. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-index.ts.txt +0 -3
  208. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-leaf.ts.txt +0 -10
  209. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-one.ts.txt +0 -1
  210. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-two.ts.txt +0 -1
  211. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-index.ts.txt +0 -3
  212. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-one.ts.txt +0 -6
  213. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-two.ts.txt +0 -6
  214. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-mixed-index.ts.txt +0 -14
  215. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-registry.ts.txt +0 -3
  216. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-barrel.ts.txt +0 -2
  217. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-index.ts.txt +0 -3
  218. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-leaf.ts.txt +0 -3
  219. package/src/cli/__tests__/fixtures/migrate-operation-declaration/docs-conflict.ts.txt +0 -8
  220. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-hoisted-array-spread.ts.txt +0 -15
  221. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-imported-array-spread.ts.txt +0 -9
  222. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-kebab-operation-id.ts.txt +0 -17
  223. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-map.ts.txt +0 -5
  224. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-mixed-array-spread.ts.txt +0 -24
  225. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-operation.ts.txt +0 -16
  226. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-snake-operation-id.ts.txt +0 -21
  227. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-duplicate-provider.ts.txt +0 -11
  228. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-id-unresolved.ts.txt +0 -7
  229. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-map.ts.txt +0 -3
  230. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-many.ts.txt +0 -18
  231. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-one.ts.txt +0 -13
  232. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoist-all.ts.txt +0 -31
  233. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoisted-const.ts.txt +0 -11
  234. package/src/cli/__tests__/fixtures/migrate-operation-declaration/imported-spread.ts.txt +0 -11
  235. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-map.ts.txt +0 -11
  236. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-cast-tail.ts.txt +0 -21
  237. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-ekitan.ts.txt +0 -11
  238. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-override.ts.txt +0 -14
  239. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-en.json +0 -1
  240. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ja.json +0 -1
  241. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ko.json +0 -1
  242. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-existing-operation-namespace-en.json +0 -8
  243. package/src/cli/__tests__/fixtures/migrate-operation-declaration/missing-english-locale.ts.txt +0 -7
  244. package/src/cli/__tests__/fixtures/migrate-operation-declaration/no-safety.ts.txt +0 -6
  245. package/src/cli/__tests__/fixtures/migrate-operation-declaration/non-literal.ts.txt +0 -7
  246. package/src/cli/__tests__/fixtures/migrate-operation-declaration/redundant-approval.ts.txt +0 -6
  247. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-binding-ambiguous.ts.txt +0 -12
  248. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported-other.ts.txt +0 -7
  249. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported.ts.txt +0 -5
  250. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-key-ambiguous.ts.txt +0 -19
  251. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-shorthand.ts.txt +0 -9
  252. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-typed.ts.txt +0 -11
  253. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-unrelated.ts.txt +0 -13
  254. package/src/cli/__tests__/fixtures/migrate-operation-declaration/safety-conflict.ts.txt +0 -7
  255. package/src/cli/__tests__/fixtures/migrate-operation-declaration/stream.ts.txt +0 -7
  256. package/src/cli/__tests__/fixtures/migrate-operation-declaration/tool-router-spread.ts.txt +0 -15
  257. package/src/cli/__tests__/fixtures/migrate-operation-declaration/unparseable.ts.txt +0 -4
  258. package/src/cli/__tests__/fixtures/migrate-operation-declaration/verbatim-template.ts.txt +0 -12
@@ -7,7 +7,9 @@ import {
7
7
  import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
8
8
  import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
9
9
  import type {
10
+ HttpAttemptContext,
10
11
  HttpClient,
12
+ HttpHeadersFactory,
11
13
  HttpMethod,
12
14
  HttpRedirectPolicy,
13
15
  HttpResponse,
@@ -29,6 +31,11 @@ import {
29
31
  shouldRetryProxyTransportAttempt,
30
32
  validateUnsafeProxyTransportRetryMethods,
31
33
  } from "./proxy-retry-policy.js";
34
+ import {
35
+ httpTelemetryErrorCode,
36
+ type HttpTelemetrySink,
37
+ type HttpAttemptTelemetryEvent,
38
+ } from "./http-telemetry.js";
32
39
  import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
33
40
  import {
34
41
  normalizeHttpRequestBody,
@@ -38,12 +45,70 @@ import {
38
45
  serializeRequestUrl,
39
46
  } from "./request-options.js";
40
47
 
48
+ import { startHttpTelemetry } from "./http-telemetry-guard.js";
49
+ import { scopedHttpTelemetry } from "./http-telemetry-binding.js";
50
+
41
51
  const DEFAULT_HTTP_BASE_URL = "http://localhost";
42
52
 
53
+ function httpAttemptFailure(
54
+ error: unknown,
55
+ status?: number,
56
+ ): Omit<HttpAttemptTelemetryEvent, "ms" | "proxyUsed"> {
57
+ try {
58
+ return {
59
+ e: httpTelemetryErrorCode(proxyTransportRetryErrorCode(error)),
60
+ status: proxyTransportRetryErrorStatus(error) ?? status,
61
+ ...(error instanceof Error
62
+ ? {
63
+ diagnostics: {
64
+ name: error.name,
65
+ message: error.message,
66
+ ...(error.cause instanceof Error
67
+ ? { cause: { name: error.cause.name, message: error.cause.message } }
68
+ : {}),
69
+ },
70
+ }
71
+ : {}),
72
+ };
73
+ } catch {
74
+ return { e: "other" };
75
+ }
76
+ }
77
+
43
78
  export type HttpClientOptions = ProxyResolutionOptions & {
44
79
  warn?: (message: string) => void;
45
80
  userAgent?: string;
46
81
  onRetrySummary?: (summary: HttpRetrySummary) => void;
82
+ /**
83
+ * Synchronous observer for HTTP attempts, retries, timeouts, status, duration,
84
+ * and observed proxy use. Observer failures never change transport behavior.
85
+ * All hooks and their property reads are guarded, including `onRetrySummary`
86
+ * and the failure reporter. Invalid asynchronous results are ignored and never
87
+ * awaited. Observer failures set the log-only `telemetryFailed` marker; the
88
+ * marker and redacted error diagnostics are excluded from headers and tenant
89
+ * metadata. Tenant `meta.retry` retains its existing response shape.
90
+ *
91
+ * After `ProviderEngine.attach`, the server wraps supported host HTTP bindings
92
+ * with the request collector. The adapter preserves arguments, direct-call,
93
+ * explicit `.call`/`.apply`, and unbound receivers, plus the original returned
94
+ * promise, response, stream, and error. Streams and SSE are observed at headers
95
+ * without consuming their bodies. SDK clients report every observed attempt
96
+ * and keep any observer configured here; routing is scoped to each call.
97
+ *
98
+ * Binding an existing adapter reuses its identity and replaces its collector:
99
+ * the latest bind receives subsequent calls, the earlier collector receives no
100
+ * further calls, and in-flight calls keep the collector captured at invocation.
101
+ * An opaque host invocation is one observed attempt with zero observed retries;
102
+ * its undisclosed retries and proxy settings cannot be inferred, and tenant
103
+ * `meta.retry` is omitted. Status, duration, and error class remain available
104
+ * in logs and telemetry headers.
105
+ *
106
+ * Non-object bindings and objects without a callable `request`, `get`, `post`,
107
+ * `put`, `delete`, `stream`, or `sse` method are left untouched, with no `http`
108
+ * telemetry sibling and one process-wide warning for this reason:
109
+ * `[apifuse] http telemetry not attached; reason=unsupported_binding_shape`.
110
+ */
111
+ httpTelemetry?: HttpTelemetrySink;
47
112
  signal?: AbortSignal;
48
113
  };
49
114
 
@@ -131,26 +196,108 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
131
196
  return Object.keys(headers).some((key) => key.toLowerCase() === needle);
132
197
  }
133
198
 
134
- function withClientHeaders(
135
- options: RequestOptions | undefined,
199
+ function withClientHeaderDefaults(
200
+ headers: Record<string, string> | undefined,
136
201
  clientOptions: HttpClientOptions,
137
202
  body: unknown,
138
- ): RequestOptions {
139
- const headers: Record<string, string> = {
203
+ ): Record<string, string> {
204
+ const merged: Record<string, string> = {
140
205
  ...(clientOptions.userAgent ? { "User-Agent": clientOptions.userAgent } : {}),
141
- ...options?.headers,
206
+ ...headers,
142
207
  };
143
208
 
144
- if (body !== undefined && !hasHeader(headers, "Content-Type")) {
145
- headers["Content-Type"] = "application/json";
209
+ if (body !== undefined && !hasHeader(merged, "Content-Type")) {
210
+ merged["Content-Type"] = "application/json";
146
211
  }
147
212
 
213
+ return merged;
214
+ }
215
+
216
+ function withClientHeaders(
217
+ options: RequestOptions | undefined,
218
+ clientOptions: HttpClientOptions,
219
+ body: unknown,
220
+ ): RequestOptions {
221
+ // The ambient client signal is merged into the per-request one here so every
222
+ // downstream read (retry loop, sleep, body consumption) sees a single signal.
223
+ // The header-factory branch needs it too, otherwise factory-header requests
224
+ // would lose ambient cancellation entirely.
225
+ const signal = mergeAbortSignals(clientOptions.signal, options?.signal);
226
+ // A header factory is resolved per issued attempt (resolveAttemptHeaders);
227
+ // the client defaults are applied to its output at that point.
228
+ if (typeof options?.headers === "function") return { ...options, signal };
148
229
  return {
149
230
  ...options,
150
- headers,
231
+ headers: withClientHeaderDefaults(options?.headers, clientOptions, body),
232
+ signal,
151
233
  };
152
234
  }
153
235
 
236
+ function headerFactoryFailure(message: string, cause?: unknown): TransportError {
237
+ // A dedicated code keeps a broken proof minter out of the retry predicate:
238
+ // toHttpTransportError would otherwise brand a plain throw as
239
+ // transport_network_error, which the default retry policy re-issues.
240
+ // retryable:false is explicit so the served error envelope does not
241
+ // advertise a deterministic signing failure as retryable, and
242
+ // category:provider_error keeps a provider-local minting bug out of the
243
+ // upstream-failure signal (status 0 would otherwise fall through the
244
+ // observability ladder to upstream_http / source upstream_failure).
245
+ return new TransportError(message, {
246
+ code: "http_header_factory_failed",
247
+ status: 0,
248
+ retryable: false,
249
+ category: "provider_error",
250
+ ...(cause instanceof Error ? { cause } : {}),
251
+ });
252
+ }
253
+
254
+ function isHeaderRecord(value: unknown): value is Record<string, string> {
255
+ return value !== null && typeof value === "object" && !Array.isArray(value);
256
+ }
257
+
258
+ // Settle `promise`, or reject with the signal's reason as soon as it aborts, so
259
+ // a pending header factory cannot outlive the request deadline or an ambient
260
+ // cancellation (native fetch has not started yet, so nothing else observes them).
261
+ function settleBeforeAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
262
+ if (!signal) return promise;
263
+ return new Promise<T>((resolve, reject) => {
264
+ const onAbort = () => reject(signal.reason);
265
+ if (signal.aborted) onAbort();
266
+ else signal.addEventListener("abort", onAbort, { once: true });
267
+ // Always attach handlers: a late rejection after the abort must settle
268
+ // here (a no-op on the already-rejected promise), never go unhandled.
269
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
270
+ });
271
+ }
272
+
273
+ async function resolveAttemptHeaders(
274
+ options: RequestOptions & { body?: unknown },
275
+ clientOptions: HttpClientOptions,
276
+ attempt: () => HttpAttemptContext,
277
+ signal: AbortSignal | undefined,
278
+ ): Promise<Record<string, string> | undefined> {
279
+ const { headers } = options;
280
+ if (typeof headers !== "function") return headers;
281
+ // The per-call timeout may have fired during proxy resolution: do not start
282
+ // the factory at all once the request is already aborted.
283
+ if (signal?.aborted) throw signal.reason;
284
+ let minted: unknown;
285
+ try {
286
+ minted = await settleBeforeAbort(
287
+ Promise.resolve().then(() => headers(attempt())),
288
+ signal,
289
+ );
290
+ } catch (error) {
291
+ // An abort is classified by toHttpTransportError (cancelled / timed out).
292
+ if (signal?.aborted) throw error;
293
+ throw headerFactoryFailure("Request header factory failed", error);
294
+ }
295
+ if (!isHeaderRecord(minted)) {
296
+ throw headerFactoryFailure("Request header factory must return a header record");
297
+ }
298
+ return withClientHeaderDefaults(minted, clientOptions, options.body);
299
+ }
300
+
154
301
  function parseHttpData(body: string, headers: Record<string, string>): unknown {
155
302
  const contentType = headers["content-type"] ?? headers["Content-Type"] ?? headers["CONTENT-TYPE"];
156
303
 
@@ -406,7 +553,7 @@ function toNativeHttpStreamResponse(
406
553
  };
407
554
  }
408
555
 
409
- function normalizeHttpMethod(method: string): HttpMethod {
556
+ function normalizeHttpMethod(method: string): Uppercase<HttpMethod> {
410
557
  switch (method.toUpperCase()) {
411
558
  case "HEAD":
412
559
  return "HEAD";
@@ -615,7 +762,7 @@ async function fetchWithHttpRedirectPolicy(
615
762
  return `Redirect loop refused target ${target}`;
616
763
  case "missing_location":
617
764
  return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
618
- }
765
+ }
619
766
  })();
620
767
  throw new HttpRedirectError(message, {
621
768
  reason: decision.reason,
@@ -702,30 +849,38 @@ function serializeHttpRequestUrl(
702
849
  async function fetchNativeHttp(
703
850
  baseUrl: string | undefined,
704
851
  url: string,
705
- method: HttpMethod,
852
+ method: Uppercase<HttpMethod>,
706
853
  options: RequestOptions & { body?: unknown },
707
854
  clientOptions: HttpClientOptions,
708
855
  warn: (message: string) => void,
709
856
  statusRetryCodes?: readonly number[],
710
857
  proxyAttemptOffset = 0,
711
858
  dedupe?: { attempted: Set<string> },
859
+ observeProxy?: (used: boolean, status?: number) => void,
860
+ nextHeaderAttempt: () => number = () => 1,
712
861
  ): Promise<NativeHttpAttemptOutcome> {
713
862
  const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
714
863
  const { requestUrl } = serializedUrl;
715
864
  const controller = options.timeout ? new AbortController() : undefined;
716
- const signal = mergeAbortSignals(clientOptions.signal, controller?.signal);
717
- const timeoutHandle = options.timeout
865
+ const signal = mergeAbortSignals(options.signal, controller?.signal);
866
+ let timeoutHandle = options.timeout
718
867
  ? setTimeout(() => controller?.abort(), options.timeout)
719
868
  : undefined;
869
+ const disarmTimeout = () => {
870
+ if (!timeoutHandle) return;
871
+ clearTimeout(timeoutHandle);
872
+ timeoutHandle = undefined;
873
+ };
720
874
 
721
875
  let proxy: string | undefined;
722
876
  try {
723
- throwIfAmbientAborted(clientOptions.signal);
877
+ throwIfAmbientAborted(options.signal);
724
878
  // Resolve inside the try (and after the timeout is armed) so allocator
725
879
  // failures are branded as TransportErrors and count against the request
726
880
  // deadline, exactly as an inline resolve would.
727
881
  proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
728
- throwIfAmbientAborted(clientOptions.signal);
882
+ observeProxy?.(Boolean(proxy));
883
+ throwIfAmbientAborted(options.signal);
729
884
  // For a registry allocator chain, skip an endpoint a prior attempt already
730
885
  // tried rather than re-issuing the same request. Returning the sentinel
731
886
  // (instead of breaking) lets the loop keep advancing the flat offset until
@@ -736,8 +891,17 @@ async function fetchNativeHttp(
736
891
  }
737
892
  dedupe.attempted.add(proxy);
738
893
  }
894
+ // Resolve after the dedupe check so a skipped duplicate offset never burns
895
+ // a single-use proof, and with the serialized URL so URL-bound proofs
896
+ // match what is actually issued.
897
+ const requestHeaders = await resolveAttemptHeaders(
898
+ options,
899
+ clientOptions,
900
+ () => ({ attempt: nextHeaderAttempt(), url: requestUrl, method }),
901
+ signal,
902
+ );
739
903
  const requestInit: NativeFetchInit = {
740
- headers: options.headers,
904
+ headers: requestHeaders,
741
905
  method,
742
906
  ...(proxy ? { proxy } : {}),
743
907
  ...(signal ? { signal } : {}),
@@ -750,6 +914,7 @@ async function fetchNativeHttp(
750
914
  requestInit,
751
915
  options.redirectPolicy,
752
916
  );
917
+ observeProxy?.(Boolean(proxy), response.status);
753
918
  const headers = Object.fromEntries(response.headers.entries());
754
919
 
755
920
  if (statusRetryCodes && response.status >= 400) {
@@ -771,7 +936,15 @@ async function fetchNativeHttp(
771
936
  });
772
937
  }
773
938
 
774
- return toNativeHttpResponse(response);
939
+ // `timeout` bounds the header phase (types.ts RequestOptions.timeout), so it
940
+ // is disarmed before the body is read: awaiting the buffered body inside
941
+ // this try brands a mid-body failure as a transport error, but must not
942
+ // extend the deadline over the download. Cancellation still applies —
943
+ // `signal` stays attached to the native response. The discarded non-2xx
944
+ // bodies drained above stay under the deadline on purpose: nothing else
945
+ // bounds them, and their content is never returned to the caller.
946
+ disarmTimeout();
947
+ return await toNativeHttpResponse(response);
775
948
  } catch (error) {
776
949
  if (error instanceof SyntaxError) {
777
950
  throw redactSensitiveError(
@@ -782,7 +955,7 @@ async function fetchNativeHttp(
782
955
  );
783
956
  }
784
957
  const transportError: NativeHttpAttemptError = redactSensitiveError(
785
- toHttpTransportError(error, clientOptions.signal, controller?.signal),
958
+ toHttpTransportError(error, options.signal, controller?.signal),
786
959
  serializedUrl.sensitiveValues,
787
960
  serializedUrl.requestUrl,
788
961
  serializedUrl.redactedUrl,
@@ -790,32 +963,41 @@ async function fetchNativeHttp(
790
963
  transportError.proxyUsed = Boolean(proxy);
791
964
  throw transportError;
792
965
  } finally {
793
- if (timeoutHandle) clearTimeout(timeoutHandle);
966
+ disarmTimeout();
794
967
  }
795
968
  }
796
969
 
797
970
  async function fetchNativeHttpStream(
798
971
  baseUrl: string | undefined,
799
972
  url: string,
800
- method: HttpMethod,
973
+ method: Uppercase<HttpMethod>,
801
974
  options: RequestOptions & { body?: unknown },
802
975
  clientOptions: HttpClientOptions,
803
976
  warn: (message: string) => void,
977
+ observeProxy?: (used: boolean) => void,
804
978
  ): Promise<HttpStreamResponse> {
805
979
  const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
806
980
  const { requestUrl } = serializedUrl;
807
981
  const controller = options.timeout ? new AbortController() : undefined;
808
- const signal = mergeAbortSignals(clientOptions.signal, controller?.signal);
982
+ const signal = mergeAbortSignals(options.signal, controller?.signal);
809
983
  const timeoutHandle = options.timeout
810
984
  ? setTimeout(() => controller?.abort(), options.timeout)
811
985
  : undefined;
812
986
 
813
987
  try {
814
- throwIfAmbientAborted(clientOptions.signal);
988
+ throwIfAmbientAborted(options.signal);
815
989
  const proxy = await resolveNativeProxy(options, clientOptions, warn);
816
- throwIfAmbientAborted(clientOptions.signal);
990
+ observeProxy?.(Boolean(proxy));
991
+ throwIfAmbientAborted(options.signal);
992
+ // Streams never retry: a header factory is resolved once, as attempt 1.
993
+ const headers = await resolveAttemptHeaders(
994
+ options,
995
+ clientOptions,
996
+ () => ({ attempt: 1, url: requestUrl, method }),
997
+ signal,
998
+ );
817
999
  const requestInit: NativeFetchInit = {
818
- headers: options.headers,
1000
+ headers,
819
1001
  method,
820
1002
  ...(proxy ? { proxy } : {}),
821
1003
  ...(signal ? { signal } : {}),
@@ -837,9 +1019,9 @@ async function fetchNativeHttpStream(
837
1019
  });
838
1020
  }
839
1021
 
840
- // Per-call timeout remains header-scoped, while the ambient request signal
841
- // stays attached to the response body for its full consumption lifetime.
842
- return toNativeHttpStreamResponse(response, serializedUrl, clientOptions.signal);
1022
+ // The stream timeout remains header-scoped; composed ambient and local
1023
+ // cancellation stays attached to native fetch for the body's full lifetime.
1024
+ return toNativeHttpStreamResponse(response, serializedUrl, options.signal);
843
1025
  } catch (error) {
844
1026
  if (error instanceof SyntaxError) {
845
1027
  throw redactSensitiveError(
@@ -850,7 +1032,7 @@ async function fetchNativeHttpStream(
850
1032
  );
851
1033
  }
852
1034
  throw redactSensitiveError(
853
- toHttpTransportError(error, clientOptions.signal, controller?.signal),
1035
+ toHttpTransportError(error, options.signal, controller?.signal),
854
1036
  serializedUrl.sensitiveValues,
855
1037
  serializedUrl.requestUrl,
856
1038
  serializedUrl.redactedUrl,
@@ -906,193 +1088,230 @@ export function createHttpClient(
906
1088
  throw redactSensitiveRequestError(error, url, options.sensitiveParams);
907
1089
  }
908
1090
  })();
909
- const retryEnabled = Boolean(
910
- retryOptions &&
911
- retryOptions.attempts > 1 &&
912
- isProxyTransportRetryMethod(methodName, retryOptions),
1091
+ const requestStarted = performance.now();
1092
+ const telemetry = startHttpTelemetry(
1093
+ scopedHttpTelemetry(clientOptions.httpTelemetry),
1094
+ retryOptions?.preset,
913
1095
  );
914
- const statusRetryEnabled = Boolean(
915
- retryEnabled &&
916
- explicitRetry &&
917
- retryOptions &&
918
- retryOptions.statusCodes.length > 0 &&
919
- headersOptions.throwOnHttpError !== false,
920
- );
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;
1096
+ try {
1097
+ return await runRequest();
1098
+ } finally {
1099
+ telemetry.finish(performance.now() - requestStarted);
1100
+ }
974
1101
 
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,
1102
+ async function runRequest(): Promise<HttpResponse> {
1103
+ const retryEnabled = Boolean(
1104
+ retryOptions &&
1105
+ retryOptions.attempts > 1 &&
1106
+ isProxyTransportRetryMethod(methodName, retryOptions),
986
1107
  );
1108
+ const statusRetryEnabled = Boolean(
1109
+ retryEnabled &&
1110
+ explicitRetry &&
1111
+ retryOptions &&
1112
+ retryOptions.statusCodes.length > 0 &&
1113
+ headersOptions.throwOnHttpError !== false,
1114
+ );
1115
+ const attemptOptions: RequestOptions & { body?: unknown } = statusRetryEnabled
1116
+ ? { ...headersOptions, throwOnHttpError: false }
1117
+ : headersOptions;
1118
+
1119
+ // Span the whole vendor chain on transport failures. Like ctx.stealth, a
1120
+ // policy-managed proxy resolves a *different* endpoint/vendor per attempt
1121
+ // (the flat proxyAttemptOffset rotates across the concatenated vendor pool
1122
+ // spans), so a transport failure should advance to the next endpoint —
1123
+ // potentially crossing into the fallback vendor — rather than stopping at
1124
+ // the per-endpoint retry budget and stranding the request on the primary
1125
+ // vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
1126
+ // only for implicit, safe-method allocator requests; explicit retry
1127
+ // policies (their documented `attempts` ceiling), unsafe methods, and
1128
+ // static/non-registry vendors keep the retry budget. Status-code retries
1129
+ // stay bounded by the retry budget regardless; only transport rotation gets
1130
+ // the full span.
1131
+ const policyProxy: ProviderProxyPolicy | undefined = (() => {
1132
+ const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
1133
+ return policy && typeof policy === "object" ? policy : undefined;
1134
+ })();
1135
+ const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
1136
+ const transportAttemptCap = retryOptions
1137
+ ? resolvePolicyTransportAttemptCap({
1138
+ policy: policyProxy,
1139
+ usesPolicyAllocator,
1140
+ retryAttempts: retryOptions.attempts,
1141
+ explicitRetry,
1142
+ method: methodName,
1143
+ })
1144
+ : 1;
1145
+
1146
+ // Track resolved endpoints across a policy-allocator chain. Successive
1147
+ // attempts rotate the flat offset across the concatenated vendor pool
1148
+ // spans, but an under-filled allocation (fewer live endpoints than the
1149
+ // configured pool size) makes the modulo mapping repeat endpoints before
1150
+ // the offset reaches the next vendor. Rather than re-hammering an
1151
+ // already-tried endpoint under backoff, fetchNativeHttp returns a skip
1152
+ // sentinel for a duplicate; the loop then advances the flat offset without
1153
+ // issuing the request, so it keeps walking toward — and into — the fallback
1154
+ // vendor's pool span instead of stalling on the primary vendor.
1155
+ // De-duplication is gated on the SAME predicate that widens the attempt cap
1156
+ // (implicit, safe-method, registry-chain rotation). It must NOT engage for
1157
+ // an explicit retry policy: there the caller's `attempts` count is the
1158
+ // contract and each attempt must issue against whatever endpoint it resolves
1159
+ // — even a repeat — instead of being silently skipped (which would collapse
1160
+ // a `poolSize: 1` + `attempts: 3` request to a single fetch).
1161
+ const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
1162
+ policy: policyProxy,
1163
+ usesPolicyAllocator,
1164
+ explicitRetry,
1165
+ method: methodName,
1166
+ });
1167
+ const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set<string>() } : undefined;
1168
+
1169
+ // Numbers the attempts a header factory sees: only requests that reached
1170
+ // header resolution count, so a failed proxy allocation (which increments
1171
+ // the telemetry `issued` count below) or a dedupe-skipped offset does not
1172
+ // make the first request that is actually built report `attempt: 2`.
1173
+ let headerAttempts = 0;
1174
+ const nextHeaderAttempt = () => {
1175
+ headerAttempts += 1;
1176
+ return headerAttempts;
1177
+ };
987
1178
 
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
- }
1179
+ const executeOnce = async (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> => {
1180
+ const started = performance.now();
1181
+ let proxyUsed = false;
1182
+ let observedStatus: number | undefined;
1183
+ const record = (event: Omit<HttpAttemptTelemetryEvent, "ms" | "proxyUsed">) => {
1184
+ telemetry.recordAttempt({ ...event, ms: performance.now() - started, proxyUsed });
1185
+ };
1186
+ try {
1187
+ const outcome = await fetchNativeHttp(
1188
+ baseUrl,
1189
+ url,
1190
+ methodName,
1191
+ attemptOptions,
1192
+ clientOptions,
1193
+ warnOnce,
1194
+ statusRetryEnabled ? retryOptions?.statusCodes : undefined,
1195
+ proxyAttemptOffset,
1196
+ dedupeContext,
1197
+ (used, status) => {
1198
+ proxyUsed = used;
1199
+ observedStatus = status;
1200
+ },
1201
+ nextHeaderAttempt,
1202
+ );
1203
+ if (!isDedupeSkipOutcome(outcome))
1204
+ record({
1205
+ status: outcome.status,
1206
+ ...(isHttpStatusOutcome(outcome)
1207
+ ? { e: "upstream_http_error", statusRetry: true }
1208
+ : {}),
1209
+ });
1210
+ return outcome;
1211
+ } catch (error) {
1212
+ // Record before the retry loop can catch/rethrow a terminal status.
1213
+ record(httpAttemptFailure(error, observedStatus));
1214
+ throw error;
1215
+ }
1216
+ };
1003
1217
 
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);
1218
+ if (!retryEnabled || !retryOptions) {
1219
+ throwIfAmbientAborted(headersOptions.signal);
1220
+ const outcome = await executeOnce();
1022
1221
  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;
1222
+ // Single-shot path never de-duplicates (dedupeContext is undefined),
1223
+ // but keep the union total.
1224
+ throw new TransportError("HTTP request produced no terminal result", {
1225
+ code: "retry_exhausted",
1226
+ });
1027
1227
  }
1028
- issued += 1;
1029
- issuedThisAttempt = true;
1030
1228
  if (isHttpStatusOutcome(outcome)) {
1031
- lastStatus = outcome.status;
1032
- if (outcome.retryable && issued < retryOptions.attempts) {
1033
- await sleep(
1034
- computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers),
1035
- clientOptions.signal,
1036
- );
1037
- continue;
1038
- }
1039
1229
  throw toUpstreamHttpError(outcome.status);
1040
1230
  }
1231
+ return outcome;
1232
+ }
1041
1233
 
1042
- const response = outcome;
1043
- if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
1044
- throw toUpstreamHttpError(response.status);
1045
- }
1234
+ let lastError: unknown;
1235
+ // `attempt` walks the flat proxy offset across the full chain span; `issued`
1236
+ // counts requests that were actually sent (skipped duplicate offsets do not
1237
+ // increment it). Retry summaries and the status-retry budget must reflect
1238
+ // issued requests, not the raw offset, so they stay accurate when partial
1239
+ // allocations skip offsets.
1240
+ let issued = 0;
1241
+ for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
1242
+ throwIfAmbientAborted(headersOptions.signal);
1243
+ // Whether this offset actually issued a request (vs. a skipped duplicate),
1244
+ // so the catch counts a thrown *transport* failure once without
1245
+ // double-counting a status outcome that already incremented before it
1246
+ // re-threw as an upstream HTTP error.
1247
+ let issuedThisAttempt = false;
1248
+ try {
1249
+ const outcome = await executeOnce(attempt - 1);
1250
+ if (isDedupeSkipOutcome(outcome)) {
1251
+ // Duplicate endpoint from a partial allocation: advance the flat
1252
+ // offset without issuing the request (no backoff, not a failure) so
1253
+ // the loop keeps rotating toward the fallback vendor.
1254
+ continue;
1255
+ }
1256
+ issued += 1;
1257
+ issuedThisAttempt = true;
1258
+ if (isHttpStatusOutcome(outcome)) {
1259
+ if (outcome.retryable && issued < retryOptions.attempts) {
1260
+ await sleep(
1261
+ computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers),
1262
+ headersOptions.signal,
1263
+ );
1264
+ continue;
1265
+ }
1266
+ throw toUpstreamHttpError(outcome.status);
1267
+ }
1046
1268
 
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;
1269
+ const response = outcome;
1270
+ if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
1271
+ throw toUpstreamHttpError(response.status);
1272
+ }
1273
+
1274
+ if (issued > 1) {
1275
+ const summary = telemetry.toTenantRetryPayload();
1276
+ if (summary) telemetry.observe(() => clientOptions.onRetrySummary?.(summary));
1277
+ }
1278
+ return response;
1279
+ } catch (error) {
1280
+ throwIfAmbientAborted(headersOptions.signal);
1281
+ if (!issuedThisAttempt) issued += 1;
1282
+ lastError = error;
1283
+ const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
1284
+ if (
1285
+ attempt < transportAttemptCap &&
1286
+ shouldRetryProxyTransportAttempt({
1287
+ error,
1288
+ explicitRetry,
1289
+ method: methodName,
1290
+ options: retryOptions,
1291
+ proxyUsed,
1292
+ })
1293
+ ) {
1294
+ await sleep(
1295
+ computeProxyTransportRetryDelayMs(retryOptions, attempt),
1296
+ headersOptions.signal,
1297
+ );
1298
+ continue;
1299
+ }
1300
+ throw error;
1081
1301
  }
1082
- throw error;
1083
1302
  }
1084
- }
1085
1303
 
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;
1304
+ // Reached when the attempt cap is consumed without a terminal outcome —
1305
+ // e.g. the final offsets of a partial allocation all resolved to
1306
+ // already-tried endpoints and were skipped. Surface the last real transport
1307
+ // failure rather than a synthetic exhaustion error.
1308
+ if (lastError !== undefined) {
1309
+ throw lastError;
1310
+ }
1311
+ throw new TransportError("HTTP retry exhausted without a terminal result", {
1312
+ code: "retry_exhausted",
1313
+ });
1092
1314
  }
1093
- throw new TransportError("HTTP retry exhausted without a terminal result", {
1094
- code: "retry_exhausted",
1095
- });
1096
1315
  }
1097
1316
 
1098
1317
  async function streamRequest(
@@ -1121,7 +1340,35 @@ export function createHttpClient(
1121
1340
  throw redactSensitiveRequestError(error, url, options.sensitiveParams);
1122
1341
  }
1123
1342
  })();
1124
- return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
1343
+ // Streams never retry. Observe the header fetch without consuming its body.
1344
+ const telemetry = startHttpTelemetry(scopedHttpTelemetry(clientOptions.httpTelemetry), "off");
1345
+ const started = performance.now();
1346
+ const alreadyAborted = headersOptions.signal?.aborted === true;
1347
+ let proxyUsed = false;
1348
+ const record = (event: Omit<HttpAttemptTelemetryEvent, "ms" | "proxyUsed">) => {
1349
+ if (alreadyAborted) return;
1350
+ telemetry.recordAttempt({ ...event, ms: performance.now() - started, proxyUsed });
1351
+ };
1352
+ try {
1353
+ const response = await fetchNativeHttpStream(
1354
+ baseUrl,
1355
+ url,
1356
+ methodName,
1357
+ headersOptions,
1358
+ clientOptions,
1359
+ warnOnce,
1360
+ (used) => {
1361
+ proxyUsed = used;
1362
+ },
1363
+ );
1364
+ record({ status: response.status });
1365
+ return response;
1366
+ } catch (error) {
1367
+ record(httpAttemptFailure(error));
1368
+ throw error;
1369
+ } finally {
1370
+ telemetry.finish(performance.now() - started);
1371
+ }
1125
1372
  }
1126
1373
 
1127
1374
  return {
@@ -1137,10 +1384,16 @@ export function createHttpClient(
1137
1384
  url,
1138
1385
  options: RequestWithMethodOptions = {},
1139
1386
  ): Promise<AsyncIterable<SseMessage>> => {
1140
- const headers = {
1141
- Accept: "text/event-stream",
1142
- ...options.headers,
1143
- };
1387
+ const callerHeaders = options.headers;
1388
+ const headers: Record<string, string> | HttpHeadersFactory =
1389
+ typeof callerHeaders === "function"
1390
+ ? async (attempt) => {
1391
+ // Pass a malformed result through unchanged so the factory
1392
+ // validation rejects it instead of spreading it into `{}`.
1393
+ const minted = await callerHeaders(attempt);
1394
+ return isHeaderRecord(minted) ? { Accept: "text/event-stream", ...minted } : minted;
1395
+ }
1396
+ : { Accept: "text/event-stream", ...callerHeaders };
1144
1397
  const response = await streamRequest(url, options.method ?? "GET", {
1145
1398
  ...options,
1146
1399
  headers,