@apifuse/provider-sdk 2.2.0-beta.2 → 2.2.0-beta.21

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 (291) hide show
  1. package/AUTHORING.md +487 -0
  2. package/CHANGELOG.md +90 -0
  3. package/README.md +49 -3
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +14 -0
  9. package/bin/apifuse-pack-smoke.ts +36 -81
  10. package/bin/apifuse-pack-types.ts +305 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +659 -111
  13. package/bin/apifuse-submit-check.ts +546 -48
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth.d.ts +2 -2
  21. package/dist/auth.js +9 -18
  22. package/dist/ceremonies/index.d.ts +9 -1
  23. package/dist/ceremonies/index.js +65 -18
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/config/loader.d.ts +176 -8
  32. package/dist/config/loader.js +424 -95
  33. package/dist/contract-serialization.d.ts +2 -2
  34. package/dist/contract-serialization.js +3 -6
  35. package/dist/contract-types.d.ts +2 -2
  36. package/dist/contract.d.ts +3 -3
  37. package/dist/contract.js +4 -6
  38. package/dist/define.d.ts +9 -1
  39. package/dist/define.js +304 -119
  40. package/dist/dev.d.ts +1 -1
  41. package/dist/dev.js +1 -1
  42. package/dist/error-resolution.d.ts +2 -0
  43. package/dist/error-resolution.js +90 -0
  44. package/dist/errors.d.ts +22 -1
  45. package/dist/errors.js +88 -0
  46. package/dist/fixture-sanitization.d.ts +26 -0
  47. package/dist/fixture-sanitization.js +216 -0
  48. package/dist/i18n/catalog.d.ts +2 -2
  49. package/dist/i18n/catalog.js +4 -10
  50. package/dist/i18n/index.d.ts +2 -2
  51. package/dist/i18n/index.js +2 -2
  52. package/dist/i18n/keys.d.ts +2 -2
  53. package/dist/index.d.ts +46 -42
  54. package/dist/index.js +41 -37
  55. package/dist/lint.d.ts +1 -1
  56. package/dist/lint.js +8 -15
  57. package/dist/native-address.d.ts +43 -0
  58. package/dist/native-address.js +281 -0
  59. package/dist/native-egress-policy.d.ts +31 -0
  60. package/dist/native-egress-policy.js +288 -0
  61. package/dist/observability.d.ts +5 -2
  62. package/dist/observability.js +48 -1
  63. package/dist/provider.d.ts +12 -11
  64. package/dist/provider.js +10 -9
  65. package/dist/public-schema-field-lint.d.ts +1 -1
  66. package/dist/recipes/gov-api.js +1 -1
  67. package/dist/runtime/auth-flow.d.ts +2 -1
  68. package/dist/runtime/auth-flow.js +4 -3
  69. package/dist/runtime/browser.d.ts +1 -1
  70. package/dist/runtime/browser.js +15 -29
  71. package/dist/runtime/cache.d.ts +1 -1
  72. package/dist/runtime/cache.js +4 -8
  73. package/dist/runtime/choice.d.ts +1 -1
  74. package/dist/runtime/choice.js +31 -35
  75. package/dist/runtime/credential.d.ts +1 -1
  76. package/dist/runtime/credential.js +1 -1
  77. package/dist/runtime/env.d.ts +1 -1
  78. package/dist/runtime/executor.d.ts +1 -1
  79. package/dist/runtime/executor.js +31 -4
  80. package/dist/runtime/http.d.ts +2 -2
  81. package/dist/runtime/http.js +387 -47
  82. package/dist/runtime/insights.d.ts +1 -1
  83. package/dist/runtime/insights.js +6 -13
  84. package/dist/runtime/instrumentation.d.ts +2 -2
  85. package/dist/runtime/instrumentation.js +345 -22
  86. package/dist/runtime/keyring.js +1 -1
  87. package/dist/runtime/namespace.js +1 -1
  88. package/dist/runtime/native-network.d.ts +127 -0
  89. package/dist/runtime/native-network.js +1298 -0
  90. package/dist/runtime/otlp.d.ts +1 -1
  91. package/dist/runtime/perf.d.ts +1 -1
  92. package/dist/runtime/provider.d.ts +1 -1
  93. package/dist/runtime/provider.js +1 -2
  94. package/dist/runtime/proxy-errors.d.ts +1 -1
  95. package/dist/runtime/proxy-errors.js +9 -7
  96. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  97. package/dist/runtime/proxy-nodemaven.js +146 -0
  98. package/dist/runtime/proxy-retry-policy.d.ts +2 -2
  99. package/dist/runtime/proxy-retry-policy.js +2 -2
  100. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  101. package/dist/runtime/proxy-telemetry.js +55 -52
  102. package/dist/runtime/redirects.d.ts +29 -0
  103. package/dist/runtime/redirects.js +36 -0
  104. package/dist/runtime/redis.d.ts +1 -1
  105. package/dist/runtime/redis.js +2 -4
  106. package/dist/runtime/request-options.d.ts +68 -1
  107. package/dist/runtime/request-options.js +548 -0
  108. package/dist/runtime/secrets.d.ts +27 -0
  109. package/dist/runtime/secrets.js +51 -0
  110. package/dist/runtime/state.d.ts +2 -2
  111. package/dist/runtime/state.js +238 -26
  112. package/dist/runtime/stealth.d.ts +5 -3
  113. package/dist/runtime/stealth.js +423 -143
  114. package/dist/runtime/stt.d.ts +1 -1
  115. package/dist/runtime/stt.js +11 -15
  116. package/dist/runtime/trace.d.ts +2 -2
  117. package/dist/runtime/trace.js +2 -4
  118. package/dist/runtime/waterfall.d.ts +1 -1
  119. package/dist/schema.d.ts +1 -1
  120. package/dist/schema.js +7 -15
  121. package/dist/serve.d.ts +1 -1
  122. package/dist/serve.js +1 -1
  123. package/dist/server/index.d.ts +7 -7
  124. package/dist/server/index.js +6 -6
  125. package/dist/server/self-test-input-tokens.d.ts +2 -1
  126. package/dist/server/self-test-input-tokens.js +18 -14
  127. package/dist/server/self-test-redaction.d.ts +1 -1
  128. package/dist/server/self-test-redaction.js +1 -1
  129. package/dist/server/self-test.d.ts +104 -3
  130. package/dist/server/self-test.js +673 -115
  131. package/dist/server/serve.d.ts +116 -4
  132. package/dist/server/serve.js +799 -128
  133. package/dist/server/types.d.ts +34 -9
  134. package/dist/server/types.js +8 -1
  135. package/dist/stateful/errors.d.ts +14 -0
  136. package/dist/stateful/errors.js +14 -0
  137. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  138. package/dist/stateful/http-provider-event-emitter.js +237 -0
  139. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  140. package/dist/stateful/http-session-owner-registry.js +210 -0
  141. package/dist/stateful/index.d.ts +18 -0
  142. package/dist/stateful/index.js +18 -0
  143. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  144. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  145. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  146. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  147. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  148. package/dist/stateful/provider-event-pipeline.js +1 -0
  149. package/dist/stateful/provider-events.d.ts +101 -0
  150. package/dist/stateful/provider-events.js +289 -0
  151. package/dist/stateful/session-key.d.ts +15 -0
  152. package/dist/stateful/session-key.js +86 -0
  153. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  154. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  155. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  156. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  157. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  158. package/dist/stateful/stateful-provider-adapter.js +287 -0
  159. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  160. package/dist/stateful/stateful-provider-observability.js +161 -0
  161. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  162. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  163. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  164. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  165. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  166. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  167. package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
  168. package/dist/stateful/stateful-provider-session-routing.js +353 -0
  169. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  170. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  171. package/dist/stateful-signing.d.ts +18 -0
  172. package/dist/stateful-signing.js +27 -0
  173. package/dist/stealth/profiles.d.ts +1 -1
  174. package/dist/stealth/profiles.js +5 -14
  175. package/dist/stream-evidence.d.ts +74 -0
  176. package/dist/stream-evidence.js +785 -0
  177. package/dist/stream.d.ts +1 -1
  178. package/dist/testing/index.d.ts +2 -2
  179. package/dist/testing/index.js +2 -2
  180. package/dist/testing/run.d.ts +32 -2
  181. package/dist/testing/run.js +478 -28
  182. package/dist/types.d.ts +342 -14
  183. package/dist/types.js +1 -0
  184. package/dist/user-input.d.ts +30 -0
  185. package/dist/user-input.js +66 -0
  186. package/package.json +16 -5
  187. package/src/auth-turn/index.ts +1 -1
  188. package/src/auth.ts +28 -86
  189. package/src/ceremonies/index.ts +103 -78
  190. package/src/cli/commands.ts +10 -0
  191. package/src/cli/create.ts +42 -35
  192. package/src/cli/prompt-assets.ts +865 -0
  193. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  194. package/src/config/loader.ts +652 -204
  195. package/src/contract-serialization.ts +5 -11
  196. package/src/contract-types.ts +2 -2
  197. package/src/contract.ts +12 -28
  198. package/src/define.ts +499 -498
  199. package/src/dev.ts +4 -9
  200. package/src/error-resolution.ts +91 -0
  201. package/src/errors.ts +110 -5
  202. package/src/fixture-sanitization.ts +247 -0
  203. package/src/i18n/catalog.ts +10 -32
  204. package/src/i18n/index.ts +2 -2
  205. package/src/i18n/keys.ts +5 -11
  206. package/src/index.ts +112 -42
  207. package/src/lint.ts +88 -152
  208. package/src/native-address.ts +340 -0
  209. package/src/native-egress-policy.ts +358 -0
  210. package/src/observability.ts +51 -1
  211. package/src/provider.ts +66 -11
  212. package/src/public-schema-field-lint.ts +7 -33
  213. package/src/recipes/gov-api.ts +2 -5
  214. package/src/runtime/auth-flow.ts +7 -7
  215. package/src/runtime/browser.ts +64 -187
  216. package/src/runtime/cache.ts +20 -67
  217. package/src/runtime/choice.ts +79 -132
  218. package/src/runtime/credential.ts +2 -2
  219. package/src/runtime/env.ts +1 -1
  220. package/src/runtime/executor.ts +43 -20
  221. package/src/runtime/http.ts +494 -57
  222. package/src/runtime/insights.ts +15 -53
  223. package/src/runtime/instrumentation.ts +495 -66
  224. package/src/runtime/keyring.ts +7 -19
  225. package/src/runtime/namespace.ts +2 -7
  226. package/src/runtime/native-network.ts +1686 -0
  227. package/src/runtime/otlp.ts +12 -23
  228. package/src/runtime/perf.ts +1 -1
  229. package/src/runtime/provider.ts +4 -9
  230. package/src/runtime/proxy-errors.ts +29 -42
  231. package/src/runtime/proxy-nodemaven.ts +221 -0
  232. package/src/runtime/proxy-retry-policy.ts +3 -3
  233. package/src/runtime/proxy-telemetry.ts +79 -77
  234. package/src/runtime/redirects.ts +66 -0
  235. package/src/runtime/redis.ts +4 -12
  236. package/src/runtime/request-options.ts +679 -9
  237. package/src/runtime/secrets.ts +64 -0
  238. package/src/runtime/state.ts +353 -133
  239. package/src/runtime/stealth.ts +505 -154
  240. package/src/runtime/stt.ts +38 -94
  241. package/src/runtime/trace.ts +14 -44
  242. package/src/runtime/waterfall.ts +5 -18
  243. package/src/schema.ts +23 -84
  244. package/src/serve.ts +1 -1
  245. package/src/server/index.ts +29 -7
  246. package/src/server/self-test-input-tokens.ts +29 -14
  247. package/src/server/self-test-redaction.ts +2 -2
  248. package/src/server/self-test.ts +857 -132
  249. package/src/server/serve.ts +1151 -328
  250. package/src/server/types.ts +12 -13
  251. package/src/stateful/README.md +146 -0
  252. package/src/stateful/errors.ts +23 -0
  253. package/src/stateful/http-provider-event-emitter.ts +314 -0
  254. package/src/stateful/http-session-owner-registry.ts +306 -0
  255. package/src/stateful/index.ts +18 -0
  256. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  257. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  258. package/src/stateful/provider-event-pipeline.ts +61 -0
  259. package/src/stateful/provider-events.ts +462 -0
  260. package/src/stateful/session-key.ts +111 -0
  261. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  262. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  263. package/src/stateful/stateful-provider-adapter.ts +562 -0
  264. package/src/stateful/stateful-provider-observability.ts +261 -0
  265. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  266. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  267. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  268. package/src/stateful/stateful-provider-session-routing.ts +555 -0
  269. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  270. package/src/stateful-signing.ts +46 -0
  271. package/src/stealth/profiles.ts +10 -26
  272. package/src/stream-evidence.ts +988 -0
  273. package/src/stream.ts +8 -19
  274. package/src/testing/index.ts +10 -2
  275. package/src/testing/run.ts +653 -74
  276. package/src/types.ts +408 -28
  277. package/src/user-input.ts +118 -0
  278. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  279. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  280. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  281. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  282. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  283. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  284. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  285. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  286. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  287. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  288. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  289. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  290. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  291. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -1,17 +1,23 @@
1
- import type { ProxyResolutionOptions } from "../config/loader";
2
- import { resolveProxyConfigAsync } from "../config/loader";
3
- import { ProviderError, TransportError } from "../errors";
4
- import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream";
1
+ import type { ProxyResolutionOptions } from "../config/loader.js";
2
+ import {
3
+ policyRotatesTransportVendorChain,
4
+ resolvePolicyTransportAttemptCap,
5
+ resolveProxyConfigAsync,
6
+ } from "../config/loader.js";
7
+ import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
8
+ import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
5
9
  import type {
6
10
  HttpClient,
7
11
  HttpMethod,
12
+ HttpRedirectPolicy,
8
13
  HttpResponse,
9
14
  HttpRetrySummary,
10
15
  HttpStreamResponse,
16
+ ProviderProxyPolicy,
11
17
  RequestOptions,
12
18
  RequestWithMethodOptions,
13
19
  SseMessage,
14
- } from "../types";
20
+ } from "../types.js";
15
21
  import {
16
22
  computeProxyAttemptIndex,
17
23
  computeProxyTransportRetryDelayMs,
@@ -22,8 +28,15 @@ import {
22
28
  proxyTransportRetryErrorStatus,
23
29
  shouldRetryProxyTransportAttempt,
24
30
  validateUnsafeProxyTransportRetryMethods,
25
- } from "./proxy-retry-policy";
26
- import { appendQueryParams, normalizeHttpRequestBody } from "./request-options";
31
+ } from "./proxy-retry-policy.js";
32
+ import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
33
+ import {
34
+ normalizeHttpRequestBody,
35
+ redactSensitiveError,
36
+ redactSensitiveRequestError,
37
+ type SerializedRequestUrl,
38
+ serializeRequestUrl,
39
+ } from "./request-options.js";
27
40
 
28
41
  const DEFAULT_HTTP_BASE_URL = "http://localhost";
29
42
 
@@ -41,16 +54,28 @@ type HttpStatusOutcome = {
41
54
  proxyUsed: boolean;
42
55
  };
43
56
 
44
- type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome;
57
+ /**
58
+ * Sentinel returned when a policy-allocator attempt resolved an endpoint that a
59
+ * prior attempt already tried (an under-filled pool repeats endpoints via the
60
+ * modulo mapping before the flat offset crosses into the next vendor). The retry
61
+ * loop advances to the next offset rather than re-issuing the request — but does
62
+ * NOT treat it as chain exhaustion, so the offset still walks into the fallback
63
+ * vendor's span.
64
+ */
65
+ type NativeHttpSkipOutcome = { kind: "dedupe-skip" };
66
+
67
+ type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome | NativeHttpSkipOutcome;
45
68
 
46
69
  type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
47
70
 
48
- function isHttpStatusOutcome(
49
- outcome: HttpResponse | HttpStatusOutcome,
50
- ): outcome is HttpStatusOutcome {
71
+ function isHttpStatusOutcome(outcome: NativeHttpAttemptOutcome): outcome is HttpStatusOutcome {
51
72
  return "kind" in outcome && outcome.kind === "http-status";
52
73
  }
53
74
 
75
+ function isDedupeSkipOutcome(outcome: NativeHttpAttemptOutcome): outcome is NativeHttpSkipOutcome {
76
+ return "kind" in outcome && outcome.kind === "dedupe-skip";
77
+ }
78
+
54
79
  async function sleep(ms: number): Promise<void> {
55
80
  if (ms <= 0) return;
56
81
  await new Promise((resolve) => setTimeout(resolve, ms));
@@ -191,9 +216,58 @@ function requireNativeResponseBody(response: Response): ReadableStream<Uint8Arra
191
216
  return response.body;
192
217
  }
193
218
 
194
- function toNativeHttpStreamResponse(response: Response): HttpStreamResponse {
219
+ function sanitizeStreamErrors(
220
+ body: ReadableStream<Uint8Array>,
221
+ serializedUrl: SerializedRequestUrl,
222
+ ): ReadableStream<Uint8Array> {
223
+ if (serializedUrl.sensitiveValues.length === 0) return body;
224
+
225
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
226
+ return new ReadableStream<Uint8Array>(
227
+ {
228
+ async pull(controller) {
229
+ try {
230
+ reader ??= body.getReader();
231
+ const chunk = await reader.read();
232
+ if (chunk.done) {
233
+ controller.close();
234
+ return;
235
+ }
236
+ controller.enqueue(chunk.value);
237
+ } catch (error) {
238
+ controller.error(
239
+ redactSensitiveError(
240
+ error,
241
+ serializedUrl.sensitiveValues,
242
+ serializedUrl.requestUrl,
243
+ serializedUrl.redactedUrl,
244
+ ),
245
+ );
246
+ }
247
+ },
248
+ async cancel(reason) {
249
+ try {
250
+ await (reader ? reader.cancel(reason) : body.cancel(reason));
251
+ } catch (error) {
252
+ throw redactSensitiveError(
253
+ error,
254
+ serializedUrl.sensitiveValues,
255
+ serializedUrl.requestUrl,
256
+ serializedUrl.redactedUrl,
257
+ );
258
+ }
259
+ },
260
+ },
261
+ { highWaterMark: 0 },
262
+ );
263
+ }
264
+
265
+ function toNativeHttpStreamResponse(
266
+ response: Response,
267
+ serializedUrl: SerializedRequestUrl,
268
+ ): HttpStreamResponse {
195
269
  const headers = Object.fromEntries(response.headers.entries());
196
- const body = requireNativeResponseBody(response);
270
+ const body = sanitizeStreamErrors(requireNativeResponseBody(response), serializedUrl);
197
271
  return {
198
272
  body,
199
273
  headers,
@@ -240,6 +314,200 @@ function resolveHttpUrl(baseUrl: string | undefined, url: string): string {
240
314
 
241
315
  type NativeFetchInit = RequestInit & { proxy?: string };
242
316
 
317
+ const MAX_HTTP_REDIRECT_HOPS = 20;
318
+ const HTTP_REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
319
+ const REDIRECT_BODY_HEADERS = new Set([
320
+ "content-encoding",
321
+ "content-language",
322
+ "content-location",
323
+ "content-type",
324
+ ]);
325
+ const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
326
+
327
+ function invalidHttpRedirectPolicy(message: string, cause?: Error): TransportError {
328
+ return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
329
+ code: "http_redirect_policy_invalid",
330
+ ...(cause ? { cause } : {}),
331
+ });
332
+ }
333
+
334
+ /** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
335
+ function normalizeHttpRedirectPolicy(value: unknown): HttpRedirectPolicy | undefined {
336
+ if (value === undefined) return undefined;
337
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
338
+ throw invalidHttpRedirectPolicy("expected an object");
339
+ }
340
+
341
+ try {
342
+ const keys = Reflect.ownKeys(value);
343
+ for (const key of keys) {
344
+ if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
345
+ throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
346
+ }
347
+ }
348
+ for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
349
+ const descriptor = Object.getOwnPropertyDescriptor(value, field);
350
+ if (!descriptor || !("value" in descriptor)) {
351
+ throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
352
+ }
353
+ }
354
+
355
+ const record = value as Record<string, unknown>;
356
+ if (record.mode !== "same-origin") {
357
+ throw invalidHttpRedirectPolicy('mode must be "same-origin"');
358
+ }
359
+ if (
360
+ typeof record.maxHops !== "number" ||
361
+ !Number.isInteger(record.maxHops) ||
362
+ record.maxHops < 0 ||
363
+ record.maxHops > MAX_HTTP_REDIRECT_HOPS
364
+ ) {
365
+ throw invalidHttpRedirectPolicy(
366
+ `maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`,
367
+ );
368
+ }
369
+
370
+ return { mode: "same-origin", maxHops: record.maxHops };
371
+ } catch (error) {
372
+ if (error instanceof TransportError) throw error;
373
+ throw invalidHttpRedirectPolicy(
374
+ "could not be inspected safely",
375
+ error instanceof Error ? error : undefined,
376
+ );
377
+ }
378
+ }
379
+
380
+ function snapshotHttpRedirectPolicy(options: RequestOptions): HttpRedirectPolicy | undefined {
381
+ try {
382
+ return normalizeHttpRedirectPolicy(options.redirectPolicy);
383
+ } catch (error) {
384
+ if (error instanceof TransportError) throw error;
385
+ throw invalidHttpRedirectPolicy(
386
+ "could not be read safely",
387
+ error instanceof Error ? error : undefined,
388
+ );
389
+ }
390
+ }
391
+
392
+ function withoutRedirectBodyHeaders(headers: HeadersInit | undefined): Headers {
393
+ const nextHeaders = new Headers(headers);
394
+ for (const name of REDIRECT_BODY_HEADERS) nextHeaders.delete(name);
395
+ return nextHeaders;
396
+ }
397
+
398
+ function redirectDiagnosticTarget(value: string): string {
399
+ try {
400
+ const parsed = new URL(value);
401
+ // Origin omits URL userinfo. Keeping only origin + path makes diagnostics
402
+ // useful while structurally excluding every query value and fragment,
403
+ // including attacker-chosen keys the provider did not declare sensitive.
404
+ const redactedQuery = parsed.search ? "?[REDACTED]" : "";
405
+ return parsed.origin === "null"
406
+ ? `${parsed.protocol}<opaque-target>`
407
+ : `${parsed.origin}${parsed.pathname}${redactedQuery}`;
408
+ } catch {
409
+ return MALFORMED_REDIRECT_TARGET;
410
+ }
411
+ }
412
+
413
+ function discardRedirectResponseBody(response: Response): void {
414
+ try {
415
+ const cancellation = response.body?.cancel();
416
+ if (cancellation) void cancellation.catch(() => undefined);
417
+ } catch {
418
+ // The redirect decision is security-significant and must not be replaced
419
+ // or delayed by an upstream body's cancellation failure. Cancellation was
420
+ // attempted; redirect evaluation continues without awaiting its completion.
421
+ }
422
+ }
423
+
424
+ async function fetchWithHttpRedirectPolicy(
425
+ requestUrl: string,
426
+ requestInit: NativeFetchInit,
427
+ policy: HttpRedirectPolicy | undefined,
428
+ ): Promise<Response> {
429
+ if (!policy) return fetch(requestUrl, requestInit);
430
+
431
+ const initialUrl = new URL(requestUrl);
432
+ if (initialUrl.protocol !== "http:" && initialUrl.protocol !== "https:") {
433
+ throw new TransportError("ctx.http redirectPolicy requires an HTTP(S) origin", {
434
+ code: "transport_invalid_url",
435
+ });
436
+ }
437
+
438
+ const initialOrigin = initialUrl.origin;
439
+ let currentUrl = requestUrl;
440
+ let method = normalizeHttpMethod(requestInit.method ?? "GET");
441
+ let body = requestInit.body;
442
+ let headers = requestInit.headers;
443
+ let followedHops = 0;
444
+ const visitedRequests = new Set([`${method} ${currentUrl}`]);
445
+
446
+ while (true) {
447
+ const response = await fetch(currentUrl, {
448
+ ...requestInit,
449
+ body,
450
+ headers,
451
+ method,
452
+ redirect: "manual",
453
+ });
454
+ if (!isRedirectStatus(response.status)) return response;
455
+
456
+ const location = response.headers.get("location");
457
+ discardRedirectResponseBody(response);
458
+ let nextUrlString: string | undefined;
459
+ try {
460
+ nextUrlString = resolveRedirectUrl(location || undefined, currentUrl);
461
+ } catch {
462
+ const target = MALFORMED_REDIRECT_TARGET;
463
+ throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
464
+ reason: "missing_location",
465
+ target,
466
+ status: response.status,
467
+ });
468
+ }
469
+
470
+ const decision = evaluateRedirectHop({
471
+ status: response.status,
472
+ method,
473
+ nextUrl: nextUrlString,
474
+ shouldStop: nextUrlString ? new URL(nextUrlString).origin !== initialOrigin : false,
475
+ redirectCount: followedHops + 1,
476
+ maxHops: policy.maxHops,
477
+ visitedRequests,
478
+ });
479
+ if (decision.kind === "stop") {
480
+ const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
481
+ const message = (() => {
482
+ switch (decision.reason) {
483
+ case "stopped":
484
+ return `Redirect policy refused cross-origin target ${target}`;
485
+ case "max_hops":
486
+ return `Redirect policy reached maxHops before target ${target}`;
487
+ case "loop":
488
+ return `Redirect loop refused target ${target}`;
489
+ case "missing_location":
490
+ return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
491
+ }
492
+ })();
493
+ throw new HttpRedirectError(message, {
494
+ reason: decision.reason,
495
+ ...(target ? { target } : {}),
496
+ status: response.status,
497
+ });
498
+ }
499
+ if (decision.nextMethod !== method) {
500
+ body = undefined;
501
+ headers = withoutRedirectBodyHeaders(headers);
502
+ }
503
+
504
+ method = decision.nextMethod;
505
+ currentUrl = decision.nextUrl;
506
+ followedHops += 1;
507
+ visitedRequests.add(`${method} ${currentUrl}`);
508
+ }
509
+ }
510
+
243
511
  async function resolveNativeProxy(
244
512
  options: RequestOptions,
245
513
  clientOptions: HttpClientOptions,
@@ -256,6 +524,9 @@ async function resolveNativeProxy(
256
524
  baseProxyAttempt: clientOptions.proxyAttempt,
257
525
  retryAttemptOffset: proxyAttemptOffset,
258
526
  }),
527
+ // Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
528
+ // supported here, so a socks5 policy fails loudly rather than downgrading.
529
+ transportProtocols: ["http"],
259
530
  telemetry: clientOptions.telemetry,
260
531
  });
261
532
  if (resolvedProxy.shouldWarn) {
@@ -285,6 +556,22 @@ function normalizeNativeFetchBody(body: unknown): string | ArrayBuffer | undefin
285
556
  return copied.buffer;
286
557
  }
287
558
 
559
+ function serializeHttpRequestUrl(
560
+ baseUrl: string | undefined,
561
+ url: string,
562
+ options: RequestOptions,
563
+ ): SerializedRequestUrl {
564
+ try {
565
+ return serializeRequestUrl(
566
+ resolveHttpUrl(baseUrl, url),
567
+ options.params,
568
+ options.sensitiveParams,
569
+ );
570
+ } catch (error) {
571
+ throw redactSensitiveRequestError(error, url, options.sensitiveParams);
572
+ }
573
+ }
574
+
288
575
  async function fetchNativeHttp(
289
576
  baseUrl: string | undefined,
290
577
  url: string,
@@ -294,8 +581,10 @@ async function fetchNativeHttp(
294
581
  warn: (message: string) => void,
295
582
  statusRetryCodes?: readonly number[],
296
583
  proxyAttemptOffset = 0,
584
+ dedupe?: { attempted: Set<string> },
297
585
  ): Promise<NativeHttpAttemptOutcome> {
298
- const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
586
+ const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
587
+ const { requestUrl } = serializedUrl;
299
588
  const controller = options.timeout ? new AbortController() : undefined;
300
589
  const timeoutHandle = options.timeout
301
590
  ? setTimeout(() => controller?.abort(), options.timeout)
@@ -303,7 +592,20 @@ async function fetchNativeHttp(
303
592
 
304
593
  let proxy: string | undefined;
305
594
  try {
595
+ // Resolve inside the try (and after the timeout is armed) so allocator
596
+ // failures are branded as TransportErrors and count against the request
597
+ // deadline, exactly as an inline resolve would.
306
598
  proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
599
+ // For a registry allocator chain, skip an endpoint a prior attempt already
600
+ // tried rather than re-issuing the same request. Returning the sentinel
601
+ // (instead of breaking) lets the loop keep advancing the flat offset until
602
+ // it crosses into the fallback vendor's pool span.
603
+ if (dedupe && proxy) {
604
+ if (dedupe.attempted.has(proxy)) {
605
+ return { kind: "dedupe-skip" };
606
+ }
607
+ dedupe.attempted.add(proxy);
608
+ }
307
609
  const requestInit: NativeFetchInit = {
308
610
  headers: options.headers,
309
611
  method,
@@ -313,9 +615,11 @@ async function fetchNativeHttp(
313
615
  if (options.body !== undefined) {
314
616
  requestInit.body = normalizeNativeFetchBody(options.body);
315
617
  }
316
- const response = await fetch(requestUrl, {
317
- ...requestInit,
318
- });
618
+ const response = await fetchWithHttpRedirectPolicy(
619
+ requestUrl,
620
+ requestInit,
621
+ options.redirectPolicy,
622
+ );
319
623
  const headers = Object.fromEntries(response.headers.entries());
320
624
 
321
625
  if (statusRetryCodes && response.status >= 400) {
@@ -340,9 +644,19 @@ async function fetchNativeHttp(
340
644
  return toNativeHttpResponse(response);
341
645
  } catch (error) {
342
646
  if (error instanceof SyntaxError) {
343
- throw error;
647
+ throw redactSensitiveError(
648
+ error,
649
+ serializedUrl.sensitiveValues,
650
+ serializedUrl.requestUrl,
651
+ serializedUrl.redactedUrl,
652
+ );
344
653
  }
345
- const transportError = toHttpTransportError(error) as NativeHttpAttemptError;
654
+ const transportError: NativeHttpAttemptError = redactSensitiveError(
655
+ toHttpTransportError(error),
656
+ serializedUrl.sensitiveValues,
657
+ serializedUrl.requestUrl,
658
+ serializedUrl.redactedUrl,
659
+ );
346
660
  transportError.proxyUsed = Boolean(proxy);
347
661
  throw transportError;
348
662
  } finally {
@@ -358,7 +672,8 @@ async function fetchNativeHttpStream(
358
672
  clientOptions: HttpClientOptions,
359
673
  warn: (message: string) => void,
360
674
  ): Promise<HttpStreamResponse> {
361
- const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
675
+ const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
676
+ const { requestUrl } = serializedUrl;
362
677
  const controller = options.timeout ? new AbortController() : undefined;
363
678
  const timeoutHandle = options.timeout
364
679
  ? setTimeout(() => controller?.abort(), options.timeout)
@@ -375,9 +690,11 @@ async function fetchNativeHttpStream(
375
690
  if (options.body !== undefined) {
376
691
  requestInit.body = normalizeNativeFetchBody(options.body);
377
692
  }
378
- const response = await fetch(requestUrl, {
379
- ...requestInit,
380
- });
693
+ const response = await fetchWithHttpRedirectPolicy(
694
+ requestUrl,
695
+ requestInit,
696
+ options.redirectPolicy,
697
+ );
381
698
 
382
699
  if (response.status >= 400 && options.throwOnHttpError !== false) {
383
700
  await drainNativeResponseBody(response);
@@ -387,12 +704,22 @@ async function fetchNativeHttpStream(
387
704
  });
388
705
  }
389
706
 
390
- return toNativeHttpStreamResponse(response);
707
+ return toNativeHttpStreamResponse(response, serializedUrl);
391
708
  } catch (error) {
392
709
  if (error instanceof SyntaxError) {
393
- throw error;
710
+ throw redactSensitiveError(
711
+ error,
712
+ serializedUrl.sensitiveValues,
713
+ serializedUrl.requestUrl,
714
+ serializedUrl.redactedUrl,
715
+ );
394
716
  }
395
- throw toHttpTransportError(error);
717
+ throw redactSensitiveError(
718
+ toHttpTransportError(error),
719
+ serializedUrl.sensitiveValues,
720
+ serializedUrl.requestUrl,
721
+ serializedUrl.redactedUrl,
722
+ );
396
723
  } finally {
397
724
  if (timeoutHandle) clearTimeout(timeoutHandle);
398
725
  }
@@ -417,22 +744,33 @@ export function createHttpClient(
417
744
  method: string,
418
745
  options: RequestOptions & { body?: unknown } = {},
419
746
  ): Promise<HttpResponse> {
420
- if (!baseUrl && !isAbsoluteUrl(url)) {
421
- throw new TransportError(
422
- "ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
423
- { code: "transport_invalid_url" },
424
- );
425
- }
426
- assertNoHttpTransportOverrides(options);
427
- const headersOptions = withClientHeaders(options, clientOptions, options.body);
428
- const methodName = normalizeHttpMethod(method);
429
- const explicitRetry = headersOptions.retry !== undefined;
430
- const retryOptions =
431
- normalizeProxyTransportRetryOptions(headersOptions.retry, {
432
- label: "HTTP",
433
- }) ??
434
- (explicitRetry ? undefined : createDefaultProxyTransportRetryOptions({ label: "HTTP" }));
435
- if (retryOptions) validateUnsafeProxyTransportRetryMethods(retryOptions, "HTTP");
747
+ const { explicitRetry, headersOptions, methodName, retryOptions } = (() => {
748
+ try {
749
+ if (!baseUrl && !isAbsoluteUrl(url)) {
750
+ throw new TransportError(
751
+ "ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
752
+ { code: "transport_invalid_url" },
753
+ );
754
+ }
755
+ assertNoHttpTransportOverrides(options);
756
+ const redirectPolicy = snapshotHttpRedirectPolicy(options);
757
+ const headersOptions = {
758
+ ...withClientHeaders(options, clientOptions, options.body),
759
+ redirectPolicy,
760
+ };
761
+ const methodName = normalizeHttpMethod(method);
762
+ const explicitRetry = headersOptions.retry !== undefined;
763
+ const retryOptions =
764
+ normalizeProxyTransportRetryOptions(headersOptions.retry, {
765
+ label: "HTTP",
766
+ }) ??
767
+ (explicitRetry ? undefined : createDefaultProxyTransportRetryOptions({ label: "HTTP" }));
768
+ if (retryOptions) validateUnsafeProxyTransportRetryMethods(retryOptions, "HTTP");
769
+ return { explicitRetry, headersOptions, methodName, retryOptions };
770
+ } catch (error) {
771
+ throw redactSensitiveRequestError(error, url, options.sensitiveParams);
772
+ }
773
+ })();
436
774
  const retryEnabled = Boolean(
437
775
  retryOptions &&
438
776
  retryOptions.attempts > 1 &&
@@ -449,6 +787,56 @@ export function createHttpClient(
449
787
  ? { ...headersOptions, throwOnHttpError: false }
450
788
  : headersOptions;
451
789
 
790
+ // Span the whole vendor chain on transport failures. Like ctx.stealth, a
791
+ // policy-managed proxy resolves a *different* endpoint/vendor per attempt
792
+ // (the flat proxyAttemptOffset rotates across the concatenated vendor pool
793
+ // spans), so a transport failure should advance to the next endpoint —
794
+ // potentially crossing into the fallback vendor — rather than stopping at
795
+ // the per-endpoint retry budget and stranding the request on the primary
796
+ // vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
797
+ // only for implicit, safe-method allocator requests; explicit retry
798
+ // policies (their documented `attempts` ceiling), unsafe methods, and
799
+ // static/non-registry vendors keep the retry budget. Status-code retries
800
+ // stay bounded by the retry budget regardless; only transport rotation gets
801
+ // the full span.
802
+ const policyProxy: ProviderProxyPolicy | undefined = (() => {
803
+ const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
804
+ return policy && typeof policy === "object" ? policy : undefined;
805
+ })();
806
+ const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
807
+ const transportAttemptCap = retryOptions
808
+ ? resolvePolicyTransportAttemptCap({
809
+ policy: policyProxy,
810
+ usesPolicyAllocator,
811
+ retryAttempts: retryOptions.attempts,
812
+ explicitRetry,
813
+ method: methodName,
814
+ })
815
+ : 1;
816
+
817
+ // Track resolved endpoints across a policy-allocator chain. Successive
818
+ // attempts rotate the flat offset across the concatenated vendor pool
819
+ // spans, but an under-filled allocation (fewer live endpoints than the
820
+ // configured pool size) makes the modulo mapping repeat endpoints before
821
+ // the offset reaches the next vendor. Rather than re-hammering an
822
+ // already-tried endpoint under backoff, fetchNativeHttp returns a skip
823
+ // sentinel for a duplicate; the loop then advances the flat offset without
824
+ // issuing the request, so it keeps walking toward — and into — the fallback
825
+ // vendor's pool span instead of stalling on the primary vendor.
826
+ // De-duplication is gated on the SAME predicate that widens the attempt cap
827
+ // (implicit, safe-method, registry-chain rotation). It must NOT engage for
828
+ // an explicit retry policy: there the caller's `attempts` count is the
829
+ // contract and each attempt must issue against whatever endpoint it resolves
830
+ // — even a repeat — instead of being silently skipped (which would collapse
831
+ // a `poolSize: 1` + `attempts: 3` request to a single fetch).
832
+ const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
833
+ policy: policyProxy,
834
+ usesPolicyAllocator,
835
+ explicitRetry,
836
+ method: methodName,
837
+ });
838
+ const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set<string>() } : undefined;
839
+
452
840
  const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
453
841
  fetchNativeHttp(
454
842
  baseUrl,
@@ -459,24 +847,52 @@ export function createHttpClient(
459
847
  warnOnce,
460
848
  statusRetryEnabled ? retryOptions?.statusCodes : undefined,
461
849
  proxyAttemptOffset,
850
+ dedupeContext,
462
851
  );
463
852
 
464
853
  if (!retryEnabled || !retryOptions) {
465
854
  const outcome = await executeOnce();
855
+ if (isDedupeSkipOutcome(outcome)) {
856
+ // Single-shot path never de-duplicates (dedupeContext is undefined),
857
+ // but keep the union total.
858
+ throw new TransportError("HTTP request produced no terminal result", {
859
+ code: "retry_exhausted",
860
+ });
861
+ }
466
862
  if (isHttpStatusOutcome(outcome)) {
467
863
  throw toUpstreamHttpError(outcome.status);
468
864
  }
469
865
  return outcome;
470
866
  }
471
867
 
868
+ let lastError: unknown;
472
869
  let lastErrorCode: string | undefined;
473
870
  let lastStatus: number | undefined;
474
- for (let attempt = 1; attempt <= retryOptions.attempts; attempt += 1) {
871
+ // `attempt` walks the flat proxy offset across the full chain span; `issued`
872
+ // counts requests that were actually sent (skipped duplicate offsets do not
873
+ // increment it). Retry summaries and the status-retry budget must reflect
874
+ // issued requests, not the raw offset, so they stay accurate when partial
875
+ // allocations skip offsets.
876
+ let issued = 0;
877
+ for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
878
+ // Whether this offset actually issued a request (vs. a skipped duplicate),
879
+ // so the catch counts a thrown *transport* failure once without
880
+ // double-counting a status outcome that already incremented before it
881
+ // re-threw as an upstream HTTP error.
882
+ let issuedThisAttempt = false;
475
883
  try {
476
884
  const outcome = await executeOnce(attempt - 1);
885
+ if (isDedupeSkipOutcome(outcome)) {
886
+ // Duplicate endpoint from a partial allocation: advance the flat
887
+ // offset without issuing the request (no backoff, not a failure) so
888
+ // the loop keeps rotating toward the fallback vendor.
889
+ continue;
890
+ }
891
+ issued += 1;
892
+ issuedThisAttempt = true;
477
893
  if (isHttpStatusOutcome(outcome)) {
478
894
  lastStatus = outcome.status;
479
- if (outcome.retryable && attempt < retryOptions.attempts) {
895
+ if (outcome.retryable && issued < retryOptions.attempts) {
480
896
  await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
481
897
  continue;
482
898
  }
@@ -488,10 +904,10 @@ export function createHttpClient(
488
904
  throw toUpstreamHttpError(response.status);
489
905
  }
490
906
 
491
- if (attempt > 1) {
907
+ if (issued > 1) {
492
908
  const summary: HttpRetrySummary = {
493
- attempts: attempt,
494
- retries: attempt - 1,
909
+ attempts: issued,
910
+ retries: issued - 1,
495
911
  ...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
496
912
  transport: "native",
497
913
  ...(lastErrorCode ? { lastErrorCode } : {}),
@@ -501,11 +917,13 @@ export function createHttpClient(
501
917
  }
502
918
  return response;
503
919
  } catch (error) {
920
+ if (!issuedThisAttempt) issued += 1;
921
+ lastError = error;
504
922
  lastErrorCode = proxyTransportRetryErrorCode(error);
505
923
  lastStatus = proxyTransportRetryErrorStatus(error);
506
924
  const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
507
925
  if (
508
- attempt < retryOptions.attempts &&
926
+ attempt < transportAttemptCap &&
509
927
  shouldRetryProxyTransportAttempt({
510
928
  error,
511
929
  explicitRetry,
@@ -521,6 +939,13 @@ export function createHttpClient(
521
939
  }
522
940
  }
523
941
 
942
+ // Reached when the attempt cap is consumed without a terminal outcome —
943
+ // e.g. the final offsets of a partial allocation all resolved to
944
+ // already-tried endpoints and were skipped. Surface the last real transport
945
+ // failure rather than a synthetic exhaustion error.
946
+ if (lastError !== undefined) {
947
+ throw lastError;
948
+ }
524
949
  throw new TransportError("HTTP retry exhausted without a terminal result", {
525
950
  code: "retry_exhausted",
526
951
  });
@@ -531,15 +956,27 @@ export function createHttpClient(
531
956
  method: string,
532
957
  options: RequestOptions & { body?: unknown } = {},
533
958
  ): Promise<HttpStreamResponse> {
534
- if (!baseUrl && !isAbsoluteUrl(url)) {
535
- throw new TransportError(
536
- "ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
537
- { code: "transport_invalid_url" },
538
- );
539
- }
540
- assertNoHttpTransportOverrides(options);
541
- const headersOptions = withClientHeaders(options, clientOptions, options.body);
542
- const methodName = normalizeHttpMethod(method);
959
+ const { headersOptions, methodName } = (() => {
960
+ try {
961
+ if (!baseUrl && !isAbsoluteUrl(url)) {
962
+ throw new TransportError(
963
+ "ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
964
+ { code: "transport_invalid_url" },
965
+ );
966
+ }
967
+ assertNoHttpTransportOverrides(options);
968
+ const redirectPolicy = snapshotHttpRedirectPolicy(options);
969
+ return {
970
+ headersOptions: {
971
+ ...withClientHeaders(options, clientOptions, options.body),
972
+ redirectPolicy,
973
+ },
974
+ methodName: normalizeHttpMethod(method),
975
+ };
976
+ } catch (error) {
977
+ throw redactSensitiveRequestError(error, url, options.sensitiveParams);
978
+ }
979
+ })();
543
980
  return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
544
981
  }
545
982