@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.40

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 (303) hide show
  1. package/AUTHORING.md +501 -13
  2. package/CHANGELOG.md +165 -1
  3. package/README.md +72 -14
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +88 -4
  6. package/bin/apifuse-dev.ts +38 -5
  7. package/bin/apifuse-pack-check.ts +22 -2
  8. package/bin/apifuse-pack-smoke.ts +57 -2
  9. package/bin/apifuse-pack-types.ts +356 -38
  10. package/bin/apifuse-perf.ts +14 -13
  11. package/bin/apifuse-record.ts +691 -68
  12. package/bin/apifuse-submit-check.ts +518 -37
  13. package/bin/apifuse-sync-assets.ts +117 -0
  14. package/bin/submit-check-delimited-text.ts +50 -0
  15. package/dist/auth-turn/index.d.ts +3 -3
  16. package/dist/auth-turn/index.js +1 -1
  17. package/dist/auth.d.ts +14 -0
  18. package/dist/auth.js +67 -0
  19. package/dist/ceremonies/index.d.ts +16 -0
  20. package/dist/ceremonies/index.js +141 -36
  21. package/dist/cli/commands.d.ts +1 -1
  22. package/dist/cli/commands.js +8 -0
  23. package/dist/cli/create.d.ts +3 -0
  24. package/dist/cli/create.js +34 -35
  25. package/dist/cli/prompt-assets.d.ts +80 -0
  26. package/dist/cli/prompt-assets.js +743 -0
  27. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  28. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  29. package/dist/cli/templates/provider/README.md.tpl +4 -4
  30. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  31. package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -1
  32. package/dist/config/loader.d.ts +177 -16
  33. package/dist/config/loader.js +424 -127
  34. package/dist/contract-serialization.js +4 -8
  35. package/dist/contract-types.d.ts +1 -0
  36. package/dist/contract.js +2 -0
  37. package/dist/declaration-validation.d.ts +32 -0
  38. package/dist/declaration-validation.js +207 -0
  39. package/dist/define.d.ts +51 -25
  40. package/dist/define.js +752 -38
  41. package/dist/error-resolution.d.ts +4 -0
  42. package/dist/error-resolution.js +122 -0
  43. package/dist/errors.d.ts +18 -0
  44. package/dist/errors.js +40 -0
  45. package/dist/fixture-sanitization.d.ts +28 -0
  46. package/dist/fixture-sanitization.js +217 -0
  47. package/dist/health-scenario.d.ts +1842 -0
  48. package/dist/health-scenario.js +624 -0
  49. package/dist/index.d.ts +19 -9
  50. package/dist/index.js +10 -6
  51. package/dist/lint.d.ts +6 -1
  52. package/dist/lint.js +362 -3
  53. package/dist/native-address.d.ts +43 -0
  54. package/dist/native-address.js +281 -0
  55. package/dist/native-egress-policy.d.ts +31 -0
  56. package/dist/native-egress-policy.js +288 -0
  57. package/dist/observability.d.ts +5 -2
  58. package/dist/observability.js +48 -1
  59. package/dist/provider.d.ts +8 -2
  60. package/dist/provider.js +3 -1
  61. package/dist/runtime/auth-flow.d.ts +5 -1
  62. package/dist/runtime/auth-flow.js +6 -0
  63. package/dist/runtime/browser.d.ts +1 -0
  64. package/dist/runtime/browser.js +492 -49
  65. package/dist/runtime/cache.d.ts +1 -0
  66. package/dist/runtime/cache.js +169 -15
  67. package/dist/runtime/choice-wordlist.d.ts +9 -0
  68. package/dist/runtime/choice-wordlist.js +138 -0
  69. package/dist/runtime/choice.d.ts +13 -1
  70. package/dist/runtime/choice.js +490 -102
  71. package/dist/runtime/executor.d.ts +2 -2
  72. package/dist/runtime/executor.js +26 -2
  73. package/dist/runtime/http.d.ts +1 -0
  74. package/dist/runtime/http.js +515 -53
  75. package/dist/runtime/instrumentation.d.ts +2 -2
  76. package/dist/runtime/instrumentation.js +366 -8
  77. package/dist/runtime/native-network-errors.d.ts +33 -0
  78. package/dist/runtime/native-network-errors.js +69 -0
  79. package/dist/runtime/native-network.d.ts +96 -0
  80. package/dist/runtime/native-network.js +1232 -0
  81. package/dist/runtime/ocr.d.ts +29 -0
  82. package/dist/runtime/ocr.js +440 -0
  83. package/dist/runtime/proxy-errors.js +6 -2
  84. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  85. package/dist/runtime/proxy-nodemaven.js +146 -0
  86. package/dist/runtime/proxy-telemetry.d.ts +80 -1
  87. package/dist/runtime/proxy-telemetry.js +154 -47
  88. package/dist/runtime/redirects.d.ts +29 -0
  89. package/dist/runtime/redirects.js +36 -0
  90. package/dist/runtime/redis.d.ts +1 -1
  91. package/dist/runtime/redis.js +4 -2
  92. package/dist/runtime/request-options.d.ts +68 -1
  93. package/dist/runtime/request-options.js +548 -0
  94. package/dist/runtime/resolver-config.d.ts +6 -0
  95. package/dist/runtime/resolver-config.js +6 -0
  96. package/dist/runtime/resolver-public.d.ts +1 -0
  97. package/dist/runtime/resolver-public.js +1 -0
  98. package/dist/runtime/resolver-shared.d.ts +3 -0
  99. package/dist/runtime/resolver-shared.js +12 -0
  100. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  101. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  102. package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
  103. package/dist/runtime/resolver-vendors/browser.js +377 -0
  104. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  105. package/dist/runtime/resolver-vendors/capsolver.js +526 -0
  106. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  107. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  108. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  109. package/dist/runtime/resolver-vendors/twocaptcha.js +407 -0
  110. package/dist/runtime/resolver-vendors/types.d.ts +94 -0
  111. package/dist/runtime/resolver-vendors/types.js +96 -0
  112. package/dist/runtime/resolver.d.ts +60 -0
  113. package/dist/runtime/resolver.js +737 -0
  114. package/dist/runtime/secrets.d.ts +27 -0
  115. package/dist/runtime/secrets.js +51 -0
  116. package/dist/runtime/state.d.ts +3 -0
  117. package/dist/runtime/state.js +277 -71
  118. package/dist/runtime/stealth-cookies.d.ts +20 -0
  119. package/dist/runtime/stealth-cookies.js +111 -0
  120. package/dist/runtime/stealth.d.ts +28 -3
  121. package/dist/runtime/stealth.js +519 -255
  122. package/dist/runtime/stt.js +1 -12
  123. package/dist/runtime/timeout.d.ts +5 -0
  124. package/dist/runtime/timeout.js +12 -0
  125. package/dist/runtime/trace-config.d.ts +12 -0
  126. package/dist/runtime/trace-config.js +61 -0
  127. package/dist/serve.d.ts +1 -1
  128. package/dist/serve.js +1 -1
  129. package/dist/server/index.d.ts +5 -3
  130. package/dist/server/index.js +3 -3
  131. package/dist/server/self-test-input-tokens.d.ts +2 -1
  132. package/dist/server/self-test-input-tokens.js +18 -14
  133. package/dist/server/self-test.d.ts +114 -0
  134. package/dist/server/self-test.js +784 -148
  135. package/dist/server/serve-implementation.d.ts +213 -0
  136. package/dist/server/serve-implementation.js +2173 -0
  137. package/dist/server/serve.d.ts +1 -70
  138. package/dist/server/serve.js +1 -1130
  139. package/dist/server/trace-output.d.ts +4 -0
  140. package/dist/server/trace-output.js +20 -0
  141. package/dist/server/types.d.ts +30 -5
  142. package/dist/server/types.js +13 -1
  143. package/dist/stateful/errors.d.ts +19 -0
  144. package/dist/stateful/errors.js +24 -0
  145. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  146. package/dist/stateful/http-provider-event-emitter.js +237 -0
  147. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  148. package/dist/stateful/http-session-owner-registry.js +210 -0
  149. package/dist/stateful/index.d.ts +18 -0
  150. package/dist/stateful/index.js +18 -0
  151. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  152. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  153. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  154. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  155. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  156. package/dist/stateful/provider-event-pipeline.js +1 -0
  157. package/dist/stateful/provider-events.d.ts +101 -0
  158. package/dist/stateful/provider-events.js +289 -0
  159. package/dist/stateful/session-key.d.ts +15 -0
  160. package/dist/stateful/session-key.js +86 -0
  161. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  162. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  163. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  164. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  165. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  166. package/dist/stateful/stateful-provider-adapter.js +287 -0
  167. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  168. package/dist/stateful/stateful-provider-observability.js +161 -0
  169. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  170. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  171. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  172. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  173. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  174. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  175. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  176. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  177. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  178. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  179. package/dist/stateful-signing.d.ts +18 -0
  180. package/dist/stateful-signing.js +27 -0
  181. package/dist/stealth/profiles.js +16 -7
  182. package/dist/stream-evidence.d.ts +74 -0
  183. package/dist/stream-evidence.js +785 -0
  184. package/dist/stream.js +7 -1
  185. package/dist/testing/index.d.ts +2 -1
  186. package/dist/testing/index.js +2 -1
  187. package/dist/testing/run.d.ts +32 -2
  188. package/dist/testing/run.js +489 -21
  189. package/dist/trace-sanitization.d.ts +5 -0
  190. package/dist/trace-sanitization.js +45 -0
  191. package/dist/types.d.ts +545 -23
  192. package/dist/types.js +1 -0
  193. package/package.json +44 -5
  194. package/src/auth-turn/index.ts +1 -1
  195. package/src/auth.ts +118 -0
  196. package/src/ceremonies/index.ts +189 -46
  197. package/src/cli/commands.ts +10 -0
  198. package/src/cli/create.ts +42 -35
  199. package/src/cli/prompt-assets.ts +865 -0
  200. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  201. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  202. package/src/cli/templates/provider/README.md.tpl +4 -4
  203. package/src/cli/templates/provider/index.ts.tpl +6 -3
  204. package/src/cli/templates/provider/operations/ping.ts.tpl +2 -1
  205. package/src/config/loader.ts +665 -163
  206. package/src/contract-serialization.ts +5 -7
  207. package/src/contract-types.ts +1 -0
  208. package/src/contract.ts +2 -0
  209. package/src/declaration-validation.ts +266 -0
  210. package/src/define.ts +970 -87
  211. package/src/error-resolution.ts +127 -0
  212. package/src/errors.ts +52 -0
  213. package/src/fixture-sanitization.ts +248 -0
  214. package/src/health-scenario.ts +875 -0
  215. package/src/index.ts +204 -8
  216. package/src/lint.ts +408 -4
  217. package/src/native-address.ts +340 -0
  218. package/src/native-egress-policy.ts +358 -0
  219. package/src/observability.ts +51 -1
  220. package/src/provider.ts +133 -0
  221. package/src/runtime/auth-flow.ts +12 -0
  222. package/src/runtime/browser.ts +661 -63
  223. package/src/runtime/cache.ts +189 -14
  224. package/src/runtime/choice-wordlist.ts +145 -0
  225. package/src/runtime/choice.ts +631 -120
  226. package/src/runtime/executor.ts +40 -7
  227. package/src/runtime/http.ts +641 -61
  228. package/src/runtime/instrumentation.ts +520 -15
  229. package/src/runtime/native-network-errors.ts +99 -0
  230. package/src/runtime/native-network.ts +1605 -0
  231. package/src/runtime/ocr.ts +523 -0
  232. package/src/runtime/proxy-errors.ts +12 -4
  233. package/src/runtime/proxy-nodemaven.ts +221 -0
  234. package/src/runtime/proxy-telemetry.ts +244 -75
  235. package/src/runtime/redirects.ts +66 -0
  236. package/src/runtime/redis.ts +7 -2
  237. package/src/runtime/request-options.ts +680 -1
  238. package/src/runtime/resolver-config.ts +6 -0
  239. package/src/runtime/resolver-public.ts +20 -0
  240. package/src/runtime/resolver-shared.ts +17 -0
  241. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  242. package/src/runtime/resolver-vendors/browser.ts +533 -0
  243. package/src/runtime/resolver-vendors/capsolver.ts +700 -0
  244. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  245. package/src/runtime/resolver-vendors/twocaptcha.ts +539 -0
  246. package/src/runtime/resolver-vendors/types.ts +212 -0
  247. package/src/runtime/resolver.ts +1103 -0
  248. package/src/runtime/secrets.ts +64 -0
  249. package/src/runtime/state.ts +394 -77
  250. package/src/runtime/stealth-cookies.ts +132 -0
  251. package/src/runtime/stealth.ts +675 -289
  252. package/src/runtime/stt.ts +1 -19
  253. package/src/runtime/timeout.ts +18 -0
  254. package/src/runtime/trace-config.ts +77 -0
  255. package/src/serve.ts +6 -1
  256. package/src/server/index.ts +37 -2
  257. package/src/server/self-test-input-tokens.ts +29 -14
  258. package/src/server/self-test.ts +1025 -175
  259. package/src/server/serve-implementation.ts +3250 -0
  260. package/src/server/serve.ts +1 -1626
  261. package/src/server/trace-output.ts +32 -0
  262. package/src/server/types.ts +13 -1
  263. package/src/stateful/README.md +146 -0
  264. package/src/stateful/errors.ts +35 -0
  265. package/src/stateful/http-provider-event-emitter.ts +314 -0
  266. package/src/stateful/http-session-owner-registry.ts +306 -0
  267. package/src/stateful/index.ts +18 -0
  268. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  269. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  270. package/src/stateful/provider-event-pipeline.ts +61 -0
  271. package/src/stateful/provider-events.ts +462 -0
  272. package/src/stateful/session-key.ts +111 -0
  273. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  274. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  275. package/src/stateful/stateful-provider-adapter.ts +562 -0
  276. package/src/stateful/stateful-provider-observability.ts +261 -0
  277. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  278. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  279. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  280. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  281. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  282. package/src/stateful-signing.ts +46 -0
  283. package/src/stealth/profiles.ts +17 -7
  284. package/src/stream-evidence.ts +988 -0
  285. package/src/stream.ts +8 -1
  286. package/src/testing/index.ts +10 -1
  287. package/src/testing/run.ts +658 -15
  288. package/src/trace-sanitization.ts +63 -0
  289. package/src/types.ts +660 -38
  290. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  291. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  292. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  293. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  294. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  295. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  296. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  297. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  298. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  299. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  300. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  301. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  302. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  303. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -1,13 +1,19 @@
1
1
  import type { ProxyResolutionOptions } from "../config/loader.js";
2
- import { resolveProxyConfigAsync } from "../config/loader.js";
3
- import { ProviderError, TransportError } from "../errors.js";
2
+ import {
3
+ policyRotatesTransportVendorChain,
4
+ resolvePolicyTransportAttemptCap,
5
+ resolveProxyConfigAsync,
6
+ } from "../config/loader.js";
7
+ import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
4
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,
@@ -23,7 +29,14 @@ import {
23
29
  shouldRetryProxyTransportAttempt,
24
30
  validateUnsafeProxyTransportRetryMethods,
25
31
  } from "./proxy-retry-policy.js";
26
- import { appendQueryParams, normalizeHttpRequestBody } from "./request-options.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
 
@@ -31,6 +44,7 @@ export type HttpClientOptions = ProxyResolutionOptions & {
31
44
  warn?: (message: string) => void;
32
45
  userAgent?: string;
33
46
  onRetrySummary?: (summary: HttpRetrySummary) => void;
47
+ signal?: AbortSignal;
34
48
  };
35
49
 
36
50
  type HttpStatusOutcome = {
@@ -41,19 +55,67 @@ type HttpStatusOutcome = {
41
55
  proxyUsed: boolean;
42
56
  };
43
57
 
44
- type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome;
58
+ /**
59
+ * Sentinel returned when a policy-allocator attempt resolved an endpoint that a
60
+ * prior attempt already tried (an under-filled pool repeats endpoints via the
61
+ * modulo mapping before the flat offset crosses into the next vendor). The retry
62
+ * loop advances to the next offset rather than re-issuing the request — but does
63
+ * NOT treat it as chain exhaustion, so the offset still walks into the fallback
64
+ * vendor's span.
65
+ */
66
+ type NativeHttpSkipOutcome = { kind: "dedupe-skip" };
67
+
68
+ type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome | NativeHttpSkipOutcome;
45
69
 
46
70
  type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
47
71
 
48
- function isHttpStatusOutcome(
49
- outcome: HttpResponse | HttpStatusOutcome,
50
- ): outcome is HttpStatusOutcome {
72
+ function isHttpStatusOutcome(outcome: NativeHttpAttemptOutcome): outcome is HttpStatusOutcome {
51
73
  return "kind" in outcome && outcome.kind === "http-status";
52
74
  }
53
75
 
54
- async function sleep(ms: number): Promise<void> {
76
+ function isDedupeSkipOutcome(outcome: NativeHttpAttemptOutcome): outcome is NativeHttpSkipOutcome {
77
+ return "kind" in outcome && outcome.kind === "dedupe-skip";
78
+ }
79
+
80
+ function toAmbientCancellationError(
81
+ signal: AbortSignal,
82
+ error: unknown = signal.reason,
83
+ ): TransportError {
84
+ if (error instanceof TransportError && error.code === "transport_cancelled") {
85
+ return error;
86
+ }
87
+ return new TransportError("Request cancelled", {
88
+ code: "transport_cancelled",
89
+ status: 0,
90
+ retryable: false,
91
+ ...(error !== undefined
92
+ ? { cause: error instanceof Error ? error : new Error(String(error)) }
93
+ : {}),
94
+ });
95
+ }
96
+
97
+ function throwIfAmbientAborted(signal: AbortSignal | undefined): void {
98
+ if (signal?.aborted) throw toAmbientCancellationError(signal);
99
+ }
100
+
101
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
102
+ throwIfAmbientAborted(signal);
55
103
  if (ms <= 0) return;
56
- await new Promise((resolve) => setTimeout(resolve, ms));
104
+ if (!signal) {
105
+ await new Promise((resolve) => setTimeout(resolve, ms));
106
+ return;
107
+ }
108
+ await new Promise<void>((resolve, reject) => {
109
+ const onAbort = () => {
110
+ clearTimeout(timer);
111
+ reject(toAmbientCancellationError(signal));
112
+ };
113
+ const timer = setTimeout(() => {
114
+ signal.removeEventListener("abort", onAbort);
115
+ resolve();
116
+ }, ms);
117
+ signal.addEventListener("abort", onAbort, { once: true });
118
+ });
57
119
  }
58
120
 
59
121
  function toUpstreamHttpError(status: number): TransportError {
@@ -107,7 +169,21 @@ function isTimeoutMessage(message: string): boolean {
107
169
  return /\b(timed out|timeout|deadline exceeded)\b/i.test(message);
108
170
  }
109
171
 
110
- function toHttpTransportError(error: unknown): TransportError {
172
+ function toHttpTransportError(
173
+ error: unknown,
174
+ ambientSignal?: AbortSignal,
175
+ timeoutSignal?: AbortSignal,
176
+ ): TransportError {
177
+ if (ambientSignal?.aborted) {
178
+ return toAmbientCancellationError(ambientSignal, error);
179
+ }
180
+ if (timeoutSignal?.aborted) {
181
+ return new TransportError("Request timed out", {
182
+ code: "transport_timeout",
183
+ status: 0,
184
+ ...(error instanceof Error ? { cause: error } : {}),
185
+ });
186
+ }
111
187
  if (error instanceof TransportError) {
112
188
  if (error.code) {
113
189
  return error;
@@ -191,9 +267,134 @@ function requireNativeResponseBody(response: Response): ReadableStream<Uint8Arra
191
267
  return response.body;
192
268
  }
193
269
 
194
- function toNativeHttpStreamResponse(response: Response): HttpStreamResponse {
270
+ function mergeAbortSignals(
271
+ ...signals: Array<AbortSignal | null | undefined>
272
+ ): AbortSignal | undefined {
273
+ const activeSignals = signals.filter((signal): signal is AbortSignal => signal != null);
274
+ if (activeSignals.length === 0) return undefined;
275
+ if (activeSignals.length === 1) return activeSignals[0];
276
+ return AbortSignal.any(activeSignals);
277
+ }
278
+
279
+ function cancelStreamOnAbort(
280
+ body: ReadableStream<Uint8Array>,
281
+ signal: AbortSignal | undefined,
282
+ ): ReadableStream<Uint8Array> {
283
+ if (!signal) return body;
284
+
285
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
286
+ let finished = false;
287
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
288
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
289
+ const onAbort = () => {
290
+ if (finished) return;
291
+ finished = true;
292
+ cleanup();
293
+ const reason = toAmbientCancellationError(signal);
294
+ streamController?.error(reason);
295
+ const cancellation = reader ? reader.cancel(reason) : body.cancel(reason);
296
+ void cancellation.catch(() => undefined).finally(() => reader?.releaseLock());
297
+ };
298
+
299
+ return new ReadableStream<Uint8Array>(
300
+ {
301
+ start(controller) {
302
+ streamController = controller;
303
+ signal.addEventListener("abort", onAbort, { once: true });
304
+ if (signal.aborted) onAbort();
305
+ },
306
+ async pull(controller) {
307
+ try {
308
+ reader ??= body.getReader();
309
+ const chunk = await reader.read();
310
+ if (finished) return;
311
+ if (chunk.done) {
312
+ finished = true;
313
+ cleanup();
314
+ controller.close();
315
+ reader.releaseLock();
316
+ return;
317
+ }
318
+ controller.enqueue(chunk.value);
319
+ } catch (error) {
320
+ if (finished) return;
321
+ finished = true;
322
+ cleanup();
323
+ controller.error(error);
324
+ reader?.releaseLock();
325
+ }
326
+ },
327
+ async cancel(reason) {
328
+ if (finished) return;
329
+ finished = true;
330
+ cleanup();
331
+ try {
332
+ await (reader ? reader.cancel(reason) : body.cancel(reason));
333
+ } finally {
334
+ reader?.releaseLock();
335
+ }
336
+ },
337
+ },
338
+ { highWaterMark: 0 },
339
+ );
340
+ }
341
+
342
+ function sanitizeStreamErrors(
343
+ body: ReadableStream<Uint8Array>,
344
+ serializedUrl: SerializedRequestUrl,
345
+ ): ReadableStream<Uint8Array> {
346
+ if (serializedUrl.sensitiveValues.length === 0) return body;
347
+
348
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
349
+ return new ReadableStream<Uint8Array>(
350
+ {
351
+ async pull(controller) {
352
+ try {
353
+ reader ??= body.getReader();
354
+ const chunk = await reader.read();
355
+ if (chunk.done) {
356
+ controller.close();
357
+ return;
358
+ }
359
+ controller.enqueue(chunk.value);
360
+ } catch (error) {
361
+ controller.error(
362
+ redactSensitiveError(
363
+ error,
364
+ serializedUrl.sensitiveValues,
365
+ serializedUrl.requestUrl,
366
+ serializedUrl.redactedUrl,
367
+ ),
368
+ );
369
+ }
370
+ },
371
+ async cancel(reason) {
372
+ try {
373
+ await (reader ? reader.cancel(reason) : body.cancel(reason));
374
+ } catch (error) {
375
+ throw redactSensitiveError(
376
+ error,
377
+ serializedUrl.sensitiveValues,
378
+ serializedUrl.requestUrl,
379
+ serializedUrl.redactedUrl,
380
+ );
381
+ }
382
+ },
383
+ },
384
+ { highWaterMark: 0 },
385
+ );
386
+ }
387
+
388
+ function toNativeHttpStreamResponse(
389
+ response: Response,
390
+ serializedUrl: SerializedRequestUrl,
391
+ signal?: AbortSignal,
392
+ ): HttpStreamResponse {
195
393
  const headers = Object.fromEntries(response.headers.entries());
196
- const body = requireNativeResponseBody(response);
394
+ const body = sanitizeStreamErrors(
395
+ cancelStreamOnAbort(requireNativeResponseBody(response), signal),
396
+ serializedUrl,
397
+ );
197
398
  return {
198
399
  body,
199
400
  headers,
@@ -240,6 +441,200 @@ function resolveHttpUrl(baseUrl: string | undefined, url: string): string {
240
441
 
241
442
  type NativeFetchInit = RequestInit & { proxy?: string };
242
443
 
444
+ const MAX_HTTP_REDIRECT_HOPS = 20;
445
+ const HTTP_REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
446
+ const REDIRECT_BODY_HEADERS = new Set([
447
+ "content-encoding",
448
+ "content-language",
449
+ "content-location",
450
+ "content-type",
451
+ ]);
452
+ const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
453
+
454
+ function invalidHttpRedirectPolicy(message: string, cause?: Error): TransportError {
455
+ return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
456
+ code: "http_redirect_policy_invalid",
457
+ ...(cause ? { cause } : {}),
458
+ });
459
+ }
460
+
461
+ /** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
462
+ function normalizeHttpRedirectPolicy(value: unknown): HttpRedirectPolicy | undefined {
463
+ if (value === undefined) return undefined;
464
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
465
+ throw invalidHttpRedirectPolicy("expected an object");
466
+ }
467
+
468
+ try {
469
+ const keys = Reflect.ownKeys(value);
470
+ for (const key of keys) {
471
+ if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
472
+ throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
473
+ }
474
+ }
475
+ for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
476
+ const descriptor = Object.getOwnPropertyDescriptor(value, field);
477
+ if (!descriptor || !("value" in descriptor)) {
478
+ throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
479
+ }
480
+ }
481
+
482
+ const record = value as Record<string, unknown>;
483
+ if (record.mode !== "same-origin") {
484
+ throw invalidHttpRedirectPolicy('mode must be "same-origin"');
485
+ }
486
+ if (
487
+ typeof record.maxHops !== "number" ||
488
+ !Number.isInteger(record.maxHops) ||
489
+ record.maxHops < 0 ||
490
+ record.maxHops > MAX_HTTP_REDIRECT_HOPS
491
+ ) {
492
+ throw invalidHttpRedirectPolicy(
493
+ `maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`,
494
+ );
495
+ }
496
+
497
+ return { mode: "same-origin", maxHops: record.maxHops };
498
+ } catch (error) {
499
+ if (error instanceof TransportError) throw error;
500
+ throw invalidHttpRedirectPolicy(
501
+ "could not be inspected safely",
502
+ error instanceof Error ? error : undefined,
503
+ );
504
+ }
505
+ }
506
+
507
+ function snapshotHttpRedirectPolicy(options: RequestOptions): HttpRedirectPolicy | undefined {
508
+ try {
509
+ return normalizeHttpRedirectPolicy(options.redirectPolicy);
510
+ } catch (error) {
511
+ if (error instanceof TransportError) throw error;
512
+ throw invalidHttpRedirectPolicy(
513
+ "could not be read safely",
514
+ error instanceof Error ? error : undefined,
515
+ );
516
+ }
517
+ }
518
+
519
+ function withoutRedirectBodyHeaders(headers: HeadersInit | undefined): Headers {
520
+ const nextHeaders = new Headers(headers);
521
+ for (const name of REDIRECT_BODY_HEADERS) nextHeaders.delete(name);
522
+ return nextHeaders;
523
+ }
524
+
525
+ function redirectDiagnosticTarget(value: string): string {
526
+ try {
527
+ const parsed = new URL(value);
528
+ // Origin omits URL userinfo. Keeping only origin + path makes diagnostics
529
+ // useful while structurally excluding every query value and fragment,
530
+ // including attacker-chosen keys the provider did not declare sensitive.
531
+ const redactedQuery = parsed.search ? "?[REDACTED]" : "";
532
+ return parsed.origin === "null"
533
+ ? `${parsed.protocol}<opaque-target>`
534
+ : `${parsed.origin}${parsed.pathname}${redactedQuery}`;
535
+ } catch {
536
+ return MALFORMED_REDIRECT_TARGET;
537
+ }
538
+ }
539
+
540
+ function discardRedirectResponseBody(response: Response): void {
541
+ try {
542
+ const cancellation = response.body?.cancel();
543
+ if (cancellation) void cancellation.catch(() => undefined);
544
+ } catch {
545
+ // The redirect decision is security-significant and must not be replaced
546
+ // or delayed by an upstream body's cancellation failure. Cancellation was
547
+ // attempted; redirect evaluation continues without awaiting its completion.
548
+ }
549
+ }
550
+
551
+ async function fetchWithHttpRedirectPolicy(
552
+ requestUrl: string,
553
+ requestInit: NativeFetchInit,
554
+ policy: HttpRedirectPolicy | undefined,
555
+ ): Promise<Response> {
556
+ if (!policy) return fetch(requestUrl, requestInit);
557
+
558
+ const initialUrl = new URL(requestUrl);
559
+ if (initialUrl.protocol !== "http:" && initialUrl.protocol !== "https:") {
560
+ throw new TransportError("ctx.http redirectPolicy requires an HTTP(S) origin", {
561
+ code: "transport_invalid_url",
562
+ });
563
+ }
564
+
565
+ const initialOrigin = initialUrl.origin;
566
+ let currentUrl = requestUrl;
567
+ let method = normalizeHttpMethod(requestInit.method ?? "GET");
568
+ let body = requestInit.body;
569
+ let headers = requestInit.headers;
570
+ let followedHops = 0;
571
+ const visitedRequests = new Set([`${method} ${currentUrl}`]);
572
+
573
+ while (true) {
574
+ const response = await fetch(currentUrl, {
575
+ ...requestInit,
576
+ body,
577
+ headers,
578
+ method,
579
+ redirect: "manual",
580
+ });
581
+ if (!isRedirectStatus(response.status)) return response;
582
+
583
+ const location = response.headers.get("location");
584
+ discardRedirectResponseBody(response);
585
+ let nextUrlString: string | undefined;
586
+ try {
587
+ nextUrlString = resolveRedirectUrl(location || undefined, currentUrl);
588
+ } catch {
589
+ const target = MALFORMED_REDIRECT_TARGET;
590
+ throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
591
+ reason: "missing_location",
592
+ target,
593
+ status: response.status,
594
+ });
595
+ }
596
+
597
+ const decision = evaluateRedirectHop({
598
+ status: response.status,
599
+ method,
600
+ nextUrl: nextUrlString,
601
+ shouldStop: nextUrlString ? new URL(nextUrlString).origin !== initialOrigin : false,
602
+ redirectCount: followedHops + 1,
603
+ maxHops: policy.maxHops,
604
+ visitedRequests,
605
+ });
606
+ if (decision.kind === "stop") {
607
+ const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
608
+ const message = (() => {
609
+ switch (decision.reason) {
610
+ case "stopped":
611
+ return `Redirect policy refused cross-origin target ${target}`;
612
+ case "max_hops":
613
+ return `Redirect policy reached maxHops before target ${target}`;
614
+ case "loop":
615
+ return `Redirect loop refused target ${target}`;
616
+ case "missing_location":
617
+ return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
618
+ }
619
+ })();
620
+ throw new HttpRedirectError(message, {
621
+ reason: decision.reason,
622
+ ...(target ? { target } : {}),
623
+ status: response.status,
624
+ });
625
+ }
626
+ if (decision.nextMethod !== method) {
627
+ body = undefined;
628
+ headers = withoutRedirectBodyHeaders(headers);
629
+ }
630
+
631
+ method = decision.nextMethod;
632
+ currentUrl = decision.nextUrl;
633
+ followedHops += 1;
634
+ visitedRequests.add(`${method} ${currentUrl}`);
635
+ }
636
+ }
637
+
243
638
  async function resolveNativeProxy(
244
639
  options: RequestOptions,
245
640
  clientOptions: HttpClientOptions,
@@ -249,13 +644,15 @@ async function resolveNativeProxy(
249
644
  const resolvedProxy = await resolveProxyConfigAsync({
250
645
  proxy: options.proxy ?? clientOptions.proxy,
251
646
  upstream: clientOptions.upstream,
252
- apifuseConfig: clientOptions.apifuseConfig,
253
647
  proxyPolicy: clientOptions.proxyPolicy,
254
648
  affinityKey: clientOptions.affinityKey,
255
649
  proxyAttempt: computeProxyAttemptIndex({
256
650
  baseProxyAttempt: clientOptions.proxyAttempt,
257
651
  retryAttemptOffset: proxyAttemptOffset,
258
652
  }),
653
+ // Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
654
+ // supported here, so a socks5 policy fails loudly rather than downgrading.
655
+ transportProtocols: ["http"],
259
656
  telemetry: clientOptions.telemetry,
260
657
  });
261
658
  if (resolvedProxy.shouldWarn) {
@@ -285,6 +682,22 @@ function normalizeNativeFetchBody(body: unknown): string | ArrayBuffer | undefin
285
682
  return copied.buffer;
286
683
  }
287
684
 
685
+ function serializeHttpRequestUrl(
686
+ baseUrl: string | undefined,
687
+ url: string,
688
+ options: RequestOptions,
689
+ ): SerializedRequestUrl {
690
+ try {
691
+ return serializeRequestUrl(
692
+ resolveHttpUrl(baseUrl, url),
693
+ options.params,
694
+ options.sensitiveParams,
695
+ );
696
+ } catch (error) {
697
+ throw redactSensitiveRequestError(error, url, options.sensitiveParams);
698
+ }
699
+ }
700
+
288
701
  async function fetchNativeHttp(
289
702
  baseUrl: string | undefined,
290
703
  url: string,
@@ -294,28 +707,48 @@ async function fetchNativeHttp(
294
707
  warn: (message: string) => void,
295
708
  statusRetryCodes?: readonly number[],
296
709
  proxyAttemptOffset = 0,
710
+ dedupe?: { attempted: Set<string> },
297
711
  ): Promise<NativeHttpAttemptOutcome> {
298
- const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
712
+ const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
713
+ const { requestUrl } = serializedUrl;
299
714
  const controller = options.timeout ? new AbortController() : undefined;
715
+ const signal = mergeAbortSignals(clientOptions.signal, controller?.signal);
300
716
  const timeoutHandle = options.timeout
301
717
  ? setTimeout(() => controller?.abort(), options.timeout)
302
718
  : undefined;
303
719
 
304
720
  let proxy: string | undefined;
305
721
  try {
722
+ throwIfAmbientAborted(clientOptions.signal);
723
+ // Resolve inside the try (and after the timeout is armed) so allocator
724
+ // failures are branded as TransportErrors and count against the request
725
+ // deadline, exactly as an inline resolve would.
306
726
  proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
727
+ throwIfAmbientAborted(clientOptions.signal);
728
+ // For a registry allocator chain, skip an endpoint a prior attempt already
729
+ // tried rather than re-issuing the same request. Returning the sentinel
730
+ // (instead of breaking) lets the loop keep advancing the flat offset until
731
+ // it crosses into the fallback vendor's pool span.
732
+ if (dedupe && proxy) {
733
+ if (dedupe.attempted.has(proxy)) {
734
+ return { kind: "dedupe-skip" };
735
+ }
736
+ dedupe.attempted.add(proxy);
737
+ }
307
738
  const requestInit: NativeFetchInit = {
308
739
  headers: options.headers,
309
740
  method,
310
741
  ...(proxy ? { proxy } : {}),
311
- signal: controller?.signal,
742
+ ...(signal ? { signal } : {}),
312
743
  };
313
744
  if (options.body !== undefined) {
314
745
  requestInit.body = normalizeNativeFetchBody(options.body);
315
746
  }
316
- const response = await fetch(requestUrl, {
317
- ...requestInit,
318
- });
747
+ const response = await fetchWithHttpRedirectPolicy(
748
+ requestUrl,
749
+ requestInit,
750
+ options.redirectPolicy,
751
+ );
319
752
  const headers = Object.fromEntries(response.headers.entries());
320
753
 
321
754
  if (statusRetryCodes && response.status >= 400) {
@@ -340,9 +773,19 @@ async function fetchNativeHttp(
340
773
  return toNativeHttpResponse(response);
341
774
  } catch (error) {
342
775
  if (error instanceof SyntaxError) {
343
- throw error;
776
+ throw redactSensitiveError(
777
+ error,
778
+ serializedUrl.sensitiveValues,
779
+ serializedUrl.requestUrl,
780
+ serializedUrl.redactedUrl,
781
+ );
344
782
  }
345
- const transportError = toHttpTransportError(error) as NativeHttpAttemptError;
783
+ const transportError: NativeHttpAttemptError = redactSensitiveError(
784
+ toHttpTransportError(error, clientOptions.signal, controller?.signal),
785
+ serializedUrl.sensitiveValues,
786
+ serializedUrl.requestUrl,
787
+ serializedUrl.redactedUrl,
788
+ );
346
789
  transportError.proxyUsed = Boolean(proxy);
347
790
  throw transportError;
348
791
  } finally {
@@ -358,26 +801,32 @@ async function fetchNativeHttpStream(
358
801
  clientOptions: HttpClientOptions,
359
802
  warn: (message: string) => void,
360
803
  ): Promise<HttpStreamResponse> {
361
- const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
804
+ const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
805
+ const { requestUrl } = serializedUrl;
362
806
  const controller = options.timeout ? new AbortController() : undefined;
807
+ const signal = mergeAbortSignals(clientOptions.signal, controller?.signal);
363
808
  const timeoutHandle = options.timeout
364
809
  ? setTimeout(() => controller?.abort(), options.timeout)
365
810
  : undefined;
366
811
 
367
812
  try {
813
+ throwIfAmbientAborted(clientOptions.signal);
368
814
  const proxy = await resolveNativeProxy(options, clientOptions, warn);
815
+ throwIfAmbientAborted(clientOptions.signal);
369
816
  const requestInit: NativeFetchInit = {
370
817
  headers: options.headers,
371
818
  method,
372
819
  ...(proxy ? { proxy } : {}),
373
- signal: controller?.signal,
820
+ ...(signal ? { signal } : {}),
374
821
  };
375
822
  if (options.body !== undefined) {
376
823
  requestInit.body = normalizeNativeFetchBody(options.body);
377
824
  }
378
- const response = await fetch(requestUrl, {
379
- ...requestInit,
380
- });
825
+ const response = await fetchWithHttpRedirectPolicy(
826
+ requestUrl,
827
+ requestInit,
828
+ options.redirectPolicy,
829
+ );
381
830
 
382
831
  if (response.status >= 400 && options.throwOnHttpError !== false) {
383
832
  await drainNativeResponseBody(response);
@@ -387,12 +836,24 @@ async function fetchNativeHttpStream(
387
836
  });
388
837
  }
389
838
 
390
- return toNativeHttpStreamResponse(response);
839
+ // Per-call timeout remains header-scoped, while the ambient request signal
840
+ // stays attached to the response body for its full consumption lifetime.
841
+ return toNativeHttpStreamResponse(response, serializedUrl, clientOptions.signal);
391
842
  } catch (error) {
392
843
  if (error instanceof SyntaxError) {
393
- throw error;
844
+ throw redactSensitiveError(
845
+ error,
846
+ serializedUrl.sensitiveValues,
847
+ serializedUrl.requestUrl,
848
+ serializedUrl.redactedUrl,
849
+ );
394
850
  }
395
- throw toHttpTransportError(error);
851
+ throw redactSensitiveError(
852
+ toHttpTransportError(error, clientOptions.signal, controller?.signal),
853
+ serializedUrl.sensitiveValues,
854
+ serializedUrl.requestUrl,
855
+ serializedUrl.redactedUrl,
856
+ );
396
857
  } finally {
397
858
  if (timeoutHandle) clearTimeout(timeoutHandle);
398
859
  }
@@ -417,22 +878,33 @@ export function createHttpClient(
417
878
  method: string,
418
879
  options: RequestOptions & { body?: unknown } = {},
419
880
  ): 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");
881
+ const { explicitRetry, headersOptions, methodName, retryOptions } = (() => {
882
+ try {
883
+ if (!baseUrl && !isAbsoluteUrl(url)) {
884
+ throw new TransportError(
885
+ "ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
886
+ { code: "transport_invalid_url" },
887
+ );
888
+ }
889
+ assertNoHttpTransportOverrides(options);
890
+ const redirectPolicy = snapshotHttpRedirectPolicy(options);
891
+ const headersOptions = {
892
+ ...withClientHeaders(options, clientOptions, options.body),
893
+ redirectPolicy,
894
+ };
895
+ const methodName = normalizeHttpMethod(method);
896
+ const explicitRetry = headersOptions.retry !== undefined;
897
+ const retryOptions =
898
+ normalizeProxyTransportRetryOptions(headersOptions.retry, {
899
+ label: "HTTP",
900
+ }) ??
901
+ (explicitRetry ? undefined : createDefaultProxyTransportRetryOptions({ label: "HTTP" }));
902
+ if (retryOptions) validateUnsafeProxyTransportRetryMethods(retryOptions, "HTTP");
903
+ return { explicitRetry, headersOptions, methodName, retryOptions };
904
+ } catch (error) {
905
+ throw redactSensitiveRequestError(error, url, options.sensitiveParams);
906
+ }
907
+ })();
436
908
  const retryEnabled = Boolean(
437
909
  retryOptions &&
438
910
  retryOptions.attempts > 1 &&
@@ -449,6 +921,56 @@ export function createHttpClient(
449
921
  ? { ...headersOptions, throwOnHttpError: false }
450
922
  : headersOptions;
451
923
 
924
+ // Span the whole vendor chain on transport failures. Like ctx.stealth, a
925
+ // policy-managed proxy resolves a *different* endpoint/vendor per attempt
926
+ // (the flat proxyAttemptOffset rotates across the concatenated vendor pool
927
+ // spans), so a transport failure should advance to the next endpoint —
928
+ // potentially crossing into the fallback vendor — rather than stopping at
929
+ // the per-endpoint retry budget and stranding the request on the primary
930
+ // vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
931
+ // only for implicit, safe-method allocator requests; explicit retry
932
+ // policies (their documented `attempts` ceiling), unsafe methods, and
933
+ // static/non-registry vendors keep the retry budget. Status-code retries
934
+ // stay bounded by the retry budget regardless; only transport rotation gets
935
+ // the full span.
936
+ const policyProxy: ProviderProxyPolicy | undefined = (() => {
937
+ const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
938
+ return policy && typeof policy === "object" ? policy : undefined;
939
+ })();
940
+ const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
941
+ const transportAttemptCap = retryOptions
942
+ ? resolvePolicyTransportAttemptCap({
943
+ policy: policyProxy,
944
+ usesPolicyAllocator,
945
+ retryAttempts: retryOptions.attempts,
946
+ explicitRetry,
947
+ method: methodName,
948
+ })
949
+ : 1;
950
+
951
+ // Track resolved endpoints across a policy-allocator chain. Successive
952
+ // attempts rotate the flat offset across the concatenated vendor pool
953
+ // spans, but an under-filled allocation (fewer live endpoints than the
954
+ // configured pool size) makes the modulo mapping repeat endpoints before
955
+ // the offset reaches the next vendor. Rather than re-hammering an
956
+ // already-tried endpoint under backoff, fetchNativeHttp returns a skip
957
+ // sentinel for a duplicate; the loop then advances the flat offset without
958
+ // issuing the request, so it keeps walking toward — and into — the fallback
959
+ // vendor's pool span instead of stalling on the primary vendor.
960
+ // De-duplication is gated on the SAME predicate that widens the attempt cap
961
+ // (implicit, safe-method, registry-chain rotation). It must NOT engage for
962
+ // an explicit retry policy: there the caller's `attempts` count is the
963
+ // contract and each attempt must issue against whatever endpoint it resolves
964
+ // — even a repeat — instead of being silently skipped (which would collapse
965
+ // a `poolSize: 1` + `attempts: 3` request to a single fetch).
966
+ const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
967
+ policy: policyProxy,
968
+ usesPolicyAllocator,
969
+ explicitRetry,
970
+ method: methodName,
971
+ });
972
+ const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set<string>() } : undefined;
973
+
452
974
  const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
453
975
  fetchNativeHttp(
454
976
  baseUrl,
@@ -459,25 +981,58 @@ export function createHttpClient(
459
981
  warnOnce,
460
982
  statusRetryEnabled ? retryOptions?.statusCodes : undefined,
461
983
  proxyAttemptOffset,
984
+ dedupeContext,
462
985
  );
463
986
 
464
987
  if (!retryEnabled || !retryOptions) {
988
+ throwIfAmbientAborted(clientOptions.signal);
465
989
  const outcome = await executeOnce();
990
+ if (isDedupeSkipOutcome(outcome)) {
991
+ // Single-shot path never de-duplicates (dedupeContext is undefined),
992
+ // but keep the union total.
993
+ throw new TransportError("HTTP request produced no terminal result", {
994
+ code: "retry_exhausted",
995
+ });
996
+ }
466
997
  if (isHttpStatusOutcome(outcome)) {
467
998
  throw toUpstreamHttpError(outcome.status);
468
999
  }
469
1000
  return outcome;
470
1001
  }
471
1002
 
1003
+ let lastError: unknown;
472
1004
  let lastErrorCode: string | undefined;
473
1005
  let lastStatus: number | undefined;
474
- for (let attempt = 1; attempt <= retryOptions.attempts; attempt += 1) {
1006
+ // `attempt` walks the flat proxy offset across the full chain span; `issued`
1007
+ // counts requests that were actually sent (skipped duplicate offsets do not
1008
+ // increment it). Retry summaries and the status-retry budget must reflect
1009
+ // issued requests, not the raw offset, so they stay accurate when partial
1010
+ // allocations skip offsets.
1011
+ let issued = 0;
1012
+ for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
1013
+ throwIfAmbientAborted(clientOptions.signal);
1014
+ // Whether this offset actually issued a request (vs. a skipped duplicate),
1015
+ // so the catch counts a thrown *transport* failure once without
1016
+ // double-counting a status outcome that already incremented before it
1017
+ // re-threw as an upstream HTTP error.
1018
+ let issuedThisAttempt = false;
475
1019
  try {
476
1020
  const outcome = await executeOnce(attempt - 1);
1021
+ if (isDedupeSkipOutcome(outcome)) {
1022
+ // Duplicate endpoint from a partial allocation: advance the flat
1023
+ // offset without issuing the request (no backoff, not a failure) so
1024
+ // the loop keeps rotating toward the fallback vendor.
1025
+ continue;
1026
+ }
1027
+ issued += 1;
1028
+ issuedThisAttempt = true;
477
1029
  if (isHttpStatusOutcome(outcome)) {
478
1030
  lastStatus = outcome.status;
479
- if (outcome.retryable && attempt < retryOptions.attempts) {
480
- await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
1031
+ if (outcome.retryable && issued < retryOptions.attempts) {
1032
+ await sleep(
1033
+ computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers),
1034
+ clientOptions.signal,
1035
+ );
481
1036
  continue;
482
1037
  }
483
1038
  throw toUpstreamHttpError(outcome.status);
@@ -488,10 +1043,10 @@ export function createHttpClient(
488
1043
  throw toUpstreamHttpError(response.status);
489
1044
  }
490
1045
 
491
- if (attempt > 1) {
1046
+ if (issued > 1) {
492
1047
  const summary: HttpRetrySummary = {
493
- attempts: attempt,
494
- retries: attempt - 1,
1048
+ attempts: issued,
1049
+ retries: issued - 1,
495
1050
  ...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
496
1051
  transport: "native",
497
1052
  ...(lastErrorCode ? { lastErrorCode } : {}),
@@ -501,11 +1056,14 @@ export function createHttpClient(
501
1056
  }
502
1057
  return response;
503
1058
  } catch (error) {
1059
+ throwIfAmbientAborted(clientOptions.signal);
1060
+ if (!issuedThisAttempt) issued += 1;
1061
+ lastError = error;
504
1062
  lastErrorCode = proxyTransportRetryErrorCode(error);
505
1063
  lastStatus = proxyTransportRetryErrorStatus(error);
506
1064
  const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
507
1065
  if (
508
- attempt < retryOptions.attempts &&
1066
+ attempt < transportAttemptCap &&
509
1067
  shouldRetryProxyTransportAttempt({
510
1068
  error,
511
1069
  explicitRetry,
@@ -514,13 +1072,23 @@ export function createHttpClient(
514
1072
  proxyUsed,
515
1073
  })
516
1074
  ) {
517
- await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt));
1075
+ await sleep(
1076
+ computeProxyTransportRetryDelayMs(retryOptions, attempt),
1077
+ clientOptions.signal,
1078
+ );
518
1079
  continue;
519
1080
  }
520
1081
  throw error;
521
1082
  }
522
1083
  }
523
1084
 
1085
+ // Reached when the attempt cap is consumed without a terminal outcome —
1086
+ // e.g. the final offsets of a partial allocation all resolved to
1087
+ // already-tried endpoints and were skipped. Surface the last real transport
1088
+ // failure rather than a synthetic exhaustion error.
1089
+ if (lastError !== undefined) {
1090
+ throw lastError;
1091
+ }
524
1092
  throw new TransportError("HTTP retry exhausted without a terminal result", {
525
1093
  code: "retry_exhausted",
526
1094
  });
@@ -531,15 +1099,27 @@ export function createHttpClient(
531
1099
  method: string,
532
1100
  options: RequestOptions & { body?: unknown } = {},
533
1101
  ): 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);
1102
+ const { headersOptions, methodName } = (() => {
1103
+ try {
1104
+ if (!baseUrl && !isAbsoluteUrl(url)) {
1105
+ throw new TransportError(
1106
+ "ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
1107
+ { code: "transport_invalid_url" },
1108
+ );
1109
+ }
1110
+ assertNoHttpTransportOverrides(options);
1111
+ const redirectPolicy = snapshotHttpRedirectPolicy(options);
1112
+ return {
1113
+ headersOptions: {
1114
+ ...withClientHeaders(options, clientOptions, options.body),
1115
+ redirectPolicy,
1116
+ },
1117
+ methodName: normalizeHttpMethod(method),
1118
+ };
1119
+ } catch (error) {
1120
+ throw redactSensitiveRequestError(error, url, options.sensitiveParams);
1121
+ }
1122
+ })();
543
1123
  return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
544
1124
  }
545
1125