@apifuse/provider-sdk 2.1.0-beta.9 → 2.2.0-beta.10

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 (263) hide show
  1. package/AUTHORING.md +240 -0
  2. package/CHANGELOG.md +93 -0
  3. package/README.md +26 -10
  4. package/SUBMISSION.md +11 -12
  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 +36 -0
  9. package/bin/apifuse-pack-smoke.ts +22 -81
  10. package/bin/apifuse-pack-types.ts +266 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +53 -70
  13. package/bin/apifuse-submit-check.ts +2177 -353
  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/auth-turn.v1.schema.json +89 -0
  20. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  21. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  22. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  23. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  24. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  25. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  26. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  27. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  28. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  29. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  30. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  31. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  32. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  33. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  34. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  35. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  36. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  37. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  38. package/dist/auth-turn/index.d.ts +195 -0
  39. package/dist/auth-turn/index.js +133 -0
  40. package/dist/auth.d.ts +76 -0
  41. package/dist/auth.js +427 -0
  42. package/dist/ceremonies/index.d.ts +1 -1
  43. package/dist/ceremonies/index.js +14 -48
  44. package/dist/cli/commands.d.ts +1 -1
  45. package/dist/cli/commands.js +8 -0
  46. package/dist/cli/create.d.ts +3 -0
  47. package/dist/cli/create.js +47 -33
  48. package/dist/cli/prompt-assets.d.ts +80 -0
  49. package/dist/cli/prompt-assets.js +743 -0
  50. package/dist/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  51. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  52. package/dist/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
  53. package/dist/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  54. package/dist/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  55. package/dist/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
  56. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  57. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  58. package/dist/cli/templates/provider/AGENTS.md.tpl +96 -0
  59. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  60. package/dist/cli/templates/provider/README.md.tpl +163 -0
  61. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  62. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  63. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  64. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  65. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  66. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  67. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  68. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  69. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  70. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  71. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  72. package/dist/config/loader.d.ts +149 -8
  73. package/dist/config/loader.js +378 -89
  74. package/dist/contract-serialization.d.ts +2 -2
  75. package/dist/contract-serialization.js +3 -6
  76. package/dist/contract-types.d.ts +2 -2
  77. package/dist/contract.d.ts +3 -3
  78. package/dist/contract.js +5 -6
  79. package/dist/define.d.ts +13 -1
  80. package/dist/define.js +245 -178
  81. package/dist/dev.d.ts +1 -1
  82. package/dist/dev.js +1 -1
  83. package/dist/errors.d.ts +4 -1
  84. package/dist/errors.js +48 -0
  85. package/dist/i18n/catalog.d.ts +2 -2
  86. package/dist/i18n/catalog.js +4 -10
  87. package/dist/i18n/index.d.ts +2 -2
  88. package/dist/i18n/index.js +2 -2
  89. package/dist/i18n/keys.d.ts +2 -2
  90. package/dist/index.d.ts +44 -41
  91. package/dist/index.js +39 -36
  92. package/dist/lint.d.ts +1 -0
  93. package/dist/lint.js +35 -15
  94. package/dist/provider.d.ts +11 -9
  95. package/dist/provider.js +9 -8
  96. package/dist/public-schema-field-lint.d.ts +1 -1
  97. package/dist/recipes/gov-api.js +1 -1
  98. package/dist/runtime/auth-flow.d.ts +1 -1
  99. package/dist/runtime/auth-flow.js +4 -2
  100. package/dist/runtime/browser.d.ts +1 -1
  101. package/dist/runtime/browser.js +214 -25
  102. package/dist/runtime/cache.d.ts +1 -1
  103. package/dist/runtime/cache.js +4 -8
  104. package/dist/runtime/choice.d.ts +1 -1
  105. package/dist/runtime/choice.js +31 -35
  106. package/dist/runtime/credential.d.ts +1 -1
  107. package/dist/runtime/credential.js +1 -1
  108. package/dist/runtime/env.d.ts +1 -1
  109. package/dist/runtime/executor.d.ts +1 -1
  110. package/dist/runtime/executor.js +15 -3
  111. package/dist/runtime/http.d.ts +2 -2
  112. package/dist/runtime/http.js +160 -344
  113. package/dist/runtime/insights.d.ts +1 -1
  114. package/dist/runtime/insights.js +6 -13
  115. package/dist/runtime/instrumentation.d.ts +2 -2
  116. package/dist/runtime/instrumentation.js +56 -19
  117. package/dist/runtime/keyring.js +1 -1
  118. package/dist/runtime/namespace.js +1 -1
  119. package/dist/runtime/otlp.d.ts +1 -1
  120. package/dist/runtime/perf.d.ts +1 -1
  121. package/dist/runtime/provider.d.ts +1 -1
  122. package/dist/runtime/provider.js +1 -2
  123. package/dist/runtime/proxy-errors.d.ts +1 -1
  124. package/dist/runtime/proxy-errors.js +9 -7
  125. package/dist/runtime/proxy-nodemaven.d.ts +35 -0
  126. package/dist/runtime/proxy-nodemaven.js +128 -0
  127. package/dist/runtime/proxy-retry-policy.d.ts +40 -0
  128. package/dist/runtime/proxy-retry-policy.js +326 -0
  129. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  130. package/dist/runtime/proxy-telemetry.js +55 -52
  131. package/dist/runtime/redis.d.ts +1 -1
  132. package/dist/runtime/redis.js +2 -4
  133. package/dist/runtime/request-options.d.ts +1 -1
  134. package/dist/runtime/secrets.d.ts +27 -0
  135. package/dist/runtime/secrets.js +51 -0
  136. package/dist/runtime/state.d.ts +2 -2
  137. package/dist/runtime/state.js +15 -4
  138. package/dist/runtime/stealth.d.ts +7 -4
  139. package/dist/runtime/stealth.js +257 -215
  140. package/dist/runtime/stt.d.ts +1 -1
  141. package/dist/runtime/stt.js +11 -15
  142. package/dist/runtime/trace.d.ts +2 -2
  143. package/dist/runtime/trace.js +2 -4
  144. package/dist/runtime/waterfall.d.ts +1 -1
  145. package/dist/schema.d.ts +1 -1
  146. package/dist/schema.js +7 -15
  147. package/dist/serve.d.ts +1 -1
  148. package/dist/serve.js +1 -1
  149. package/dist/server/index.d.ts +7 -3
  150. package/dist/server/index.js +6 -2
  151. package/dist/server/self-test-input-tokens.d.ts +1 -0
  152. package/dist/server/self-test-input-tokens.js +37 -0
  153. package/dist/server/self-test-redaction.d.ts +20 -0
  154. package/dist/server/self-test-redaction.js +70 -0
  155. package/dist/server/self-test-token.d.ts +30 -0
  156. package/dist/server/self-test-token.js +50 -0
  157. package/dist/server/self-test.d.ts +199 -0
  158. package/dist/server/self-test.js +1113 -0
  159. package/dist/server/serve.d.ts +14 -3
  160. package/dist/server/serve.js +135 -64
  161. package/dist/server/types.d.ts +10 -9
  162. package/dist/server/types.js +3 -7
  163. package/dist/stealth/profiles.d.ts +1 -1
  164. package/dist/stealth/profiles.js +5 -14
  165. package/dist/stream.d.ts +1 -1
  166. package/dist/testing/index.d.ts +2 -2
  167. package/dist/testing/index.js +2 -2
  168. package/dist/testing/run.d.ts +1 -1
  169. package/dist/testing/run.js +12 -15
  170. package/dist/types.d.ts +237 -1
  171. package/dist/user-input.d.ts +30 -0
  172. package/dist/user-input.js +66 -0
  173. package/package.json +16 -5
  174. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  175. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  176. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  177. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  178. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  179. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  180. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  181. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  182. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  183. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  184. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  185. package/src/auth-turn/fixtures/valid/form.json +14 -0
  186. package/src/auth-turn/fixtures/valid/message.json +13 -0
  187. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  188. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  189. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  190. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  191. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  192. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  193. package/src/auth-turn/index.ts +177 -0
  194. package/src/auth.ts +728 -0
  195. package/src/ceremonies/index.ts +33 -121
  196. package/src/cli/commands.ts +10 -0
  197. package/src/cli/create.ts +69 -99
  198. package/src/cli/prompt-assets.ts +865 -0
  199. package/src/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  200. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  201. package/src/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
  202. package/src/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  203. package/src/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  204. package/src/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
  205. package/src/cli/templates/provider/AGENTS.md.tpl +96 -0
  206. package/src/cli/templates/provider/README.md.tpl +7 -4
  207. package/src/config/loader.ts +543 -208
  208. package/src/contract-serialization.ts +5 -11
  209. package/src/contract-types.ts +2 -2
  210. package/src/contract.ts +13 -28
  211. package/src/define.ts +397 -528
  212. package/src/dev.ts +4 -9
  213. package/src/errors.ts +58 -5
  214. package/src/i18n/catalog.ts +10 -32
  215. package/src/i18n/index.ts +2 -2
  216. package/src/i18n/keys.ts +5 -11
  217. package/src/index.ts +64 -41
  218. package/src/lint.ts +122 -159
  219. package/src/provider.ts +40 -9
  220. package/src/public-schema-field-lint.ts +7 -33
  221. package/src/recipes/gov-api.ts +2 -5
  222. package/src/runtime/auth-flow.ts +6 -6
  223. package/src/runtime/browser.ts +320 -151
  224. package/src/runtime/cache.ts +20 -67
  225. package/src/runtime/choice.ts +79 -132
  226. package/src/runtime/credential.ts +2 -2
  227. package/src/runtime/env.ts +1 -1
  228. package/src/runtime/executor.ts +23 -20
  229. package/src/runtime/http.ts +216 -539
  230. package/src/runtime/insights.ts +15 -53
  231. package/src/runtime/instrumentation.ts +78 -65
  232. package/src/runtime/keyring.ts +7 -19
  233. package/src/runtime/namespace.ts +2 -7
  234. package/src/runtime/otlp.ts +12 -23
  235. package/src/runtime/perf.ts +1 -1
  236. package/src/runtime/provider.ts +4 -9
  237. package/src/runtime/proxy-errors.ts +29 -42
  238. package/src/runtime/proxy-nodemaven.ts +178 -0
  239. package/src/runtime/proxy-retry-policy.ts +469 -0
  240. package/src/runtime/proxy-telemetry.ts +79 -77
  241. package/src/runtime/redis.ts +4 -12
  242. package/src/runtime/request-options.ts +4 -13
  243. package/src/runtime/secrets.ts +64 -0
  244. package/src/runtime/state.ts +41 -110
  245. package/src/runtime/stealth.ts +331 -369
  246. package/src/runtime/stt.ts +38 -94
  247. package/src/runtime/trace.ts +14 -44
  248. package/src/runtime/waterfall.ts +5 -18
  249. package/src/schema.ts +23 -84
  250. package/src/serve.ts +1 -1
  251. package/src/server/index.ts +44 -3
  252. package/src/server/self-test-input-tokens.ts +46 -0
  253. package/src/server/self-test-redaction.ts +97 -0
  254. package/src/server/self-test-token.ts +70 -0
  255. package/src/server/self-test.ts +1450 -0
  256. package/src/server/serve.ts +206 -216
  257. package/src/server/types.ts +7 -19
  258. package/src/stealth/profiles.ts +10 -26
  259. package/src/stream.ts +8 -19
  260. package/src/testing/index.ts +2 -2
  261. package/src/testing/run.ts +24 -64
  262. package/src/types.ts +274 -1
  263. package/src/user-input.ts +118 -0
@@ -1,31 +1,34 @@
1
- import type { ProxyResolutionOptions } from "../config/loader";
2
- import { resolveProxyConfigAsync } from "../config/loader";
3
- import { ProviderError, TransportError } from "../errors";
1
+ import type { ProxyResolutionOptions } from "../config/loader.js";
4
2
  import {
5
- parseSseStream,
6
- readableBytes,
7
- readableLines,
8
- readableTextChunks,
9
- } from "../stream";
3
+ policyRotatesTransportVendorChain,
4
+ resolvePolicyTransportAttemptCap,
5
+ resolveProxyConfigAsync,
6
+ } from "../config/loader.js";
7
+ import { ProviderError, TransportError } from "../errors.js";
8
+ import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
10
9
  import type {
11
10
  HttpClient,
12
11
  HttpMethod,
13
12
  HttpResponse,
14
- HttpRetryOptions,
15
13
  HttpRetrySummary,
16
14
  HttpStreamResponse,
15
+ ProviderProxyPolicy,
17
16
  RequestOptions,
18
17
  RequestWithMethodOptions,
19
18
  SseMessage,
20
- } from "../types";
19
+ } from "../types.js";
21
20
  import {
22
- HttpRetryAfterPolicy,
23
- HttpRetryDelayStrategy,
24
- HttpRetryJitter,
25
- HttpRetryPreset,
26
- HttpRetryUnsafeMethodPolicy,
27
- } from "../types";
28
- import { appendQueryParams, normalizeHttpRequestBody } from "./request-options";
21
+ computeProxyAttemptIndex,
22
+ computeProxyTransportRetryDelayMs,
23
+ createDefaultProxyTransportRetryOptions,
24
+ isProxyTransportRetryMethod,
25
+ normalizeProxyTransportRetryOptions,
26
+ proxyTransportRetryErrorCode,
27
+ proxyTransportRetryErrorStatus,
28
+ shouldRetryProxyTransportAttempt,
29
+ validateUnsafeProxyTransportRetryMethods,
30
+ } from "./proxy-retry-policy.js";
31
+ import { appendQueryParams, normalizeHttpRequestBody } from "./request-options.js";
29
32
 
30
33
  const DEFAULT_HTTP_BASE_URL = "http://localhost";
31
34
 
@@ -35,445 +38,34 @@ export type HttpClientOptions = ProxyResolutionOptions & {
35
38
  onRetrySummary?: (summary: HttpRetrySummary) => void;
36
39
  };
37
40
 
38
- type NormalizedRetryOptions = Required<
39
- Pick<
40
- HttpRetryOptions,
41
- | "attempts"
42
- | "delayStrategy"
43
- | "baseDelayMs"
44
- | "maxDelayMs"
45
- | "jitter"
46
- | "retryAfter"
47
- | "unsafeMethodPolicy"
48
- >
49
- > & {
50
- preset?: HttpRetryPreset;
51
- methods: readonly string[];
52
- statusCodes: readonly number[];
53
- errorCodes: readonly string[];
54
- };
55
-
56
41
  type HttpStatusOutcome = {
57
42
  kind: "http-status";
58
43
  status: number;
59
44
  headers: Record<string, string>;
60
45
  retryable: boolean;
46
+ proxyUsed: boolean;
61
47
  };
62
48
 
63
- function isHttpStatusOutcome(
64
- outcome: HttpResponse | HttpStatusOutcome,
65
- ): outcome is HttpStatusOutcome {
66
- return "kind" in outcome && outcome.kind === "http-status";
67
- }
68
-
69
- const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS"] as const;
70
- const DEFAULT_RETRY_ERROR_CODES = [
71
- "transport_network_error",
72
- "transport_timeout",
73
- ] as const;
74
- const SAFE_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504] as const;
75
- const RATE_LIMIT_RETRY_STATUS_CODES = [429, 503] as const;
76
- const KNOWN_RETRY_METHODS = new Set([
77
- "GET",
78
- "HEAD",
79
- "POST",
80
- "PUT",
81
- "DELETE",
82
- "OPTIONS",
83
- "TRACE",
84
- "PATCH",
85
- ]);
86
- const UNSAFE_RETRY_METHODS = new Set([
87
- "POST",
88
- "PUT",
89
- "PATCH",
90
- "DELETE",
91
- "TRACE",
92
- ]);
93
- const MAX_RETRY_ATTEMPTS = 8;
94
- const MAX_RETRY_DELAY_MS = 30_000;
95
-
96
- function hasOwnValue<T extends string>(
97
- values: Record<string, T>,
98
- value: unknown,
99
- ): value is T {
100
- if (typeof value !== "string") return false;
101
- return Object.values(values).some((candidate) => candidate === value);
102
- }
103
-
104
- function createInvalidRetryPolicyError(message: string): ProviderError {
105
- return new ProviderError(message, { code: "retry_invalid_policy" });
106
- }
107
-
108
- function createRetryOptions(preset: HttpRetryPreset): NormalizedRetryOptions {
109
- switch (preset) {
110
- case HttpRetryPreset.Off:
111
- return {
112
- preset,
113
- attempts: 1,
114
- methods: DEFAULT_RETRY_METHODS,
115
- statusCodes: [],
116
- errorCodes: DEFAULT_RETRY_ERROR_CODES,
117
- delayStrategy: HttpRetryDelayStrategy.Exponential,
118
- baseDelayMs: 100,
119
- maxDelayMs: 1_000,
120
- jitter: HttpRetryJitter.Full,
121
- retryAfter: HttpRetryAfterPolicy.Ignore,
122
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
123
- };
124
- case HttpRetryPreset.SafeRead:
125
- return {
126
- preset,
127
- attempts: 3,
128
- methods: DEFAULT_RETRY_METHODS,
129
- statusCodes: SAFE_RETRY_STATUS_CODES,
130
- errorCodes: DEFAULT_RETRY_ERROR_CODES,
131
- delayStrategy: HttpRetryDelayStrategy.Exponential,
132
- baseDelayMs: 100,
133
- maxDelayMs: 2_000,
134
- jitter: HttpRetryJitter.Full,
135
- retryAfter: HttpRetryAfterPolicy.Cap,
136
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
137
- };
138
- case HttpRetryPreset.AggressiveRead:
139
- return {
140
- preset,
141
- attempts: 4,
142
- methods: DEFAULT_RETRY_METHODS,
143
- statusCodes: SAFE_RETRY_STATUS_CODES,
144
- errorCodes: DEFAULT_RETRY_ERROR_CODES,
145
- delayStrategy: HttpRetryDelayStrategy.Exponential,
146
- baseDelayMs: 150,
147
- maxDelayMs: 5_000,
148
- jitter: HttpRetryJitter.Full,
149
- retryAfter: HttpRetryAfterPolicy.Cap,
150
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
151
- };
152
- case HttpRetryPreset.RateLimitAware:
153
- return {
154
- preset,
155
- attempts: 3,
156
- methods: DEFAULT_RETRY_METHODS,
157
- statusCodes: RATE_LIMIT_RETRY_STATUS_CODES,
158
- errorCodes: ["transport_timeout"],
159
- delayStrategy: HttpRetryDelayStrategy.Exponential,
160
- baseDelayMs: 250,
161
- maxDelayMs: 5_000,
162
- jitter: HttpRetryJitter.Equal,
163
- retryAfter: HttpRetryAfterPolicy.Respect,
164
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
165
- };
166
- case HttpRetryPreset.TransportTransient:
167
- return {
168
- preset,
169
- attempts: 3,
170
- methods: DEFAULT_RETRY_METHODS,
171
- statusCodes: [],
172
- errorCodes: DEFAULT_RETRY_ERROR_CODES,
173
- delayStrategy: HttpRetryDelayStrategy.Exponential,
174
- baseDelayMs: 100,
175
- maxDelayMs: 1_000,
176
- jitter: HttpRetryJitter.Full,
177
- retryAfter: HttpRetryAfterPolicy.Ignore,
178
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
179
- };
180
- }
181
- throw createInvalidRetryPolicyError(`Unknown HTTP retry preset: ${preset}`);
182
- }
183
-
184
- function clampPositiveInteger(
185
- value: number | undefined,
186
- fallback: number,
187
- max: number,
188
- ): number {
189
- if (value === undefined) return fallback;
190
- if (!Number.isFinite(value) || value < 1) return fallback;
191
- return Math.min(Math.floor(value), max);
192
- }
193
-
194
- function clampDelay(value: number | undefined, fallback: number): number {
195
- if (value === undefined) return fallback;
196
- if (!Number.isFinite(value) || value < 0) return fallback;
197
- return Math.min(Math.floor(value), MAX_RETRY_DELAY_MS);
198
- }
199
-
200
- function normalizeRetryOptions(
201
- retry: RequestOptions["retry"],
202
- ): NormalizedRetryOptions | undefined {
203
- if (retry === undefined || retry === false) {
204
- return undefined;
205
- }
206
- if (retry === true) {
207
- return createRetryOptions(HttpRetryPreset.TransportTransient);
208
- }
209
- if (typeof retry === "string") {
210
- if (!hasOwnValue(HttpRetryPreset, retry)) {
211
- throw createInvalidRetryPolicyError(
212
- `Unknown HTTP retry preset: ${retry}`,
213
- );
214
- }
215
- return createRetryOptions(retry);
216
- }
217
- if (typeof retry !== "object" || retry === null) {
218
- throw createInvalidRetryPolicyError("HTTP retry policy must be an object");
219
- }
220
- if (Array.isArray(retry)) {
221
- throw createInvalidRetryPolicyError(
222
- "HTTP retry policy must be a plain object",
223
- );
224
- }
225
-
226
- validateRetryOptionsShape(retry);
227
- const base = createRetryOptions(
228
- retry.preset ?? HttpRetryPreset.TransportTransient,
229
- );
230
- const maxDelayMs = clampDelay(retry.maxDelayMs, base.maxDelayMs);
231
- const normalized: NormalizedRetryOptions = {
232
- preset: retry.preset ?? base.preset,
233
- attempts: clampPositiveInteger(
234
- retry.attempts,
235
- base.attempts,
236
- MAX_RETRY_ATTEMPTS,
237
- ),
238
- methods:
239
- retry.methods?.map((method) => method.toUpperCase()) ?? base.methods,
240
- statusCodes:
241
- retry.statusCodes?.filter((status) => Number.isInteger(status)) ??
242
- base.statusCodes,
243
- errorCodes: retry.errorCodes ?? base.errorCodes,
244
- delayStrategy: retry.delayStrategy ?? base.delayStrategy,
245
- baseDelayMs: clampDelay(retry.baseDelayMs, base.baseDelayMs),
246
- maxDelayMs,
247
- jitter: retry.jitter ?? base.jitter,
248
- retryAfter: retry.retryAfter ?? base.retryAfter,
249
- unsafeMethodPolicy: retry.unsafeMethodPolicy ?? base.unsafeMethodPolicy,
250
- };
251
-
252
- return normalized;
253
- }
254
-
255
- function validateRetryOptionsShape(retry: HttpRetryOptions): void {
256
- if (
257
- retry.preset !== undefined &&
258
- !hasOwnValue(HttpRetryPreset, retry.preset)
259
- ) {
260
- throw createInvalidRetryPolicyError(
261
- `Unknown HTTP retry preset: ${String(retry.preset)}`,
262
- );
263
- }
264
- if (
265
- retry.delayStrategy !== undefined &&
266
- !hasOwnValue(HttpRetryDelayStrategy, retry.delayStrategy)
267
- ) {
268
- throw createInvalidRetryPolicyError(
269
- `Unknown HTTP retry delay strategy: ${String(retry.delayStrategy)}`,
270
- );
271
- }
272
- if (
273
- retry.jitter !== undefined &&
274
- !hasOwnValue(HttpRetryJitter, retry.jitter)
275
- ) {
276
- throw createInvalidRetryPolicyError(
277
- `Unknown HTTP retry jitter policy: ${String(retry.jitter)}`,
278
- );
279
- }
280
- if (
281
- retry.retryAfter !== undefined &&
282
- !hasOwnValue(HttpRetryAfterPolicy, retry.retryAfter)
283
- ) {
284
- throw createInvalidRetryPolicyError(
285
- `Unknown HTTP retry-after policy: ${String(retry.retryAfter)}`,
286
- );
287
- }
288
- if (
289
- retry.unsafeMethodPolicy !== undefined &&
290
- !hasOwnValue(HttpRetryUnsafeMethodPolicy, retry.unsafeMethodPolicy)
291
- ) {
292
- throw createInvalidRetryPolicyError(
293
- `Unknown HTTP retry unsafe method policy: ${String(retry.unsafeMethodPolicy)}`,
294
- );
295
- }
296
- if (retry.methods !== undefined) {
297
- if (!Array.isArray(retry.methods)) {
298
- throw createInvalidRetryPolicyError(
299
- "HTTP retry methods must be an array",
300
- );
301
- }
302
- const nonStringMethods = retry.methods.filter(
303
- (method) => typeof method !== "string",
304
- );
305
- if (nonStringMethods.length > 0) {
306
- throw createInvalidRetryPolicyError(
307
- "HTTP retry methods must contain only strings",
308
- );
309
- }
310
- const unknownMethods = retry.methods
311
- .map((method) => method.toUpperCase())
312
- .filter((method) => !KNOWN_RETRY_METHODS.has(method));
313
- if (unknownMethods.length > 0) {
314
- throw createInvalidRetryPolicyError(
315
- `Unknown HTTP retry method(s): ${unknownMethods.join(", ")}`,
316
- );
317
- }
318
- }
319
- if (retry.statusCodes !== undefined) {
320
- if (!Array.isArray(retry.statusCodes)) {
321
- throw createInvalidRetryPolicyError(
322
- "HTTP retry statusCodes must be an array",
323
- );
324
- }
325
- const invalidStatusCodes = retry.statusCodes.filter(
326
- (status) =>
327
- !Number.isInteger(status) ||
328
- Number(status) < 100 ||
329
- Number(status) > 599,
330
- );
331
- if (invalidStatusCodes.length > 0) {
332
- throw createInvalidRetryPolicyError(
333
- "HTTP retry statusCodes must contain HTTP status integers in [100, 599]",
334
- );
335
- }
336
- }
337
- if (retry.errorCodes !== undefined) {
338
- if (!Array.isArray(retry.errorCodes)) {
339
- throw createInvalidRetryPolicyError(
340
- "HTTP retry errorCodes must be an array",
341
- );
342
- }
343
- const nonStringErrorCodes = retry.errorCodes.filter(
344
- (errorCode) => typeof errorCode !== "string",
345
- );
346
- if (nonStringErrorCodes.length > 0) {
347
- throw createInvalidRetryPolicyError(
348
- "HTTP retry errorCodes must contain only strings",
349
- );
350
- }
351
- }
352
- if (
353
- retry.preset === HttpRetryPreset.Off &&
354
- ((retry.attempts !== undefined && retry.attempts > 1) ||
355
- (retry.statusCodes !== undefined && retry.statusCodes.length > 0))
356
- ) {
357
- throw createInvalidRetryPolicyError(
358
- "HTTP retry preset off cannot be combined with retry-enabling overrides",
359
- );
360
- }
361
- }
362
-
363
- function validateUnsafeRetryMethods(options: NormalizedRetryOptions): void {
364
- if (
365
- options.unsafeMethodPolicy ===
366
- HttpRetryUnsafeMethodPolicy.AllowExplicitUnsafe
367
- ) {
368
- return;
369
- }
370
- const unsafeMethods = options.methods.filter((method) =>
371
- UNSAFE_RETRY_METHODS.has(method.toUpperCase()),
372
- );
373
- if (unsafeMethods.length === 0) return;
374
-
375
- throw new ProviderError(
376
- `HTTP retry methods include unsafe method(s): ${unsafeMethods.join(", ")}`,
377
- { code: "retry_unsafe_method" },
378
- );
379
- }
380
-
381
- function isMethodRetryable(
382
- method: HttpMethod,
383
- options: NormalizedRetryOptions,
384
- ): boolean {
385
- return options.methods
386
- .map((allowedMethod) => allowedMethod.toUpperCase())
387
- .includes(method.toUpperCase());
388
- }
389
-
390
- function retryErrorCode(error: unknown): string | undefined {
391
- if (error instanceof TransportError) {
392
- return error.code;
393
- }
394
- if (error && typeof error === "object" && "code" in error) {
395
- const code = Reflect.get(error, "code");
396
- return typeof code === "string" ? code : undefined;
397
- }
398
- return undefined;
399
- }
49
+ /**
50
+ * Sentinel returned when a policy-allocator attempt resolved an endpoint that a
51
+ * prior attempt already tried (an under-filled pool repeats endpoints via the
52
+ * modulo mapping before the flat offset crosses into the next vendor). The retry
53
+ * loop advances to the next offset rather than re-issuing the request — but does
54
+ * NOT treat it as chain exhaustion, so the offset still walks into the fallback
55
+ * vendor's span.
56
+ */
57
+ type NativeHttpSkipOutcome = { kind: "dedupe-skip" };
400
58
 
401
- function retryErrorStatus(error: unknown): number | undefined {
402
- if (error instanceof TransportError) {
403
- return error.status ?? error.upstreamStatus;
404
- }
405
- return undefined;
406
- }
59
+ type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome | NativeHttpSkipOutcome;
407
60
 
408
- function shouldRetryTransportError(
409
- error: unknown,
410
- options: NormalizedRetryOptions,
411
- ): boolean {
412
- const code = retryErrorCode(error);
413
- return Boolean(code && options.errorCodes.includes(code));
414
- }
61
+ type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
415
62
 
416
- function retryAfterHeader(headers: Record<string, string>): string | undefined {
417
- for (const [name, value] of Object.entries(headers)) {
418
- if (name.toLowerCase() === "retry-after") return value;
419
- }
420
- return undefined;
63
+ function isHttpStatusOutcome(outcome: NativeHttpAttemptOutcome): outcome is HttpStatusOutcome {
64
+ return "kind" in outcome && outcome.kind === "http-status";
421
65
  }
422
66
 
423
- function parseRetryAfterMs(
424
- headers: Record<string, string>,
425
- now: number = Date.now(),
426
- ): number | undefined {
427
- const value = retryAfterHeader(headers);
428
- if (!value) return undefined;
429
- const seconds = Number(value);
430
- if (Number.isFinite(seconds)) {
431
- return Math.max(0, Math.floor(seconds * 1_000));
432
- }
433
- const dateMs = Date.parse(value);
434
- if (!Number.isNaN(dateMs)) {
435
- return Math.max(0, dateMs - now);
436
- }
437
- return undefined;
438
- }
439
-
440
- function computeRetryDelayMs(
441
- options: NormalizedRetryOptions,
442
- attemptIndex: number,
443
- headers?: Record<string, string>,
444
- ): number {
445
- const multiplier =
446
- options.delayStrategy === HttpRetryDelayStrategy.Exponential
447
- ? 2 ** Math.max(0, attemptIndex - 1)
448
- : 1;
449
- const configuredDelay = Math.min(
450
- options.baseDelayMs * multiplier,
451
- options.maxDelayMs,
452
- );
453
- const retryAfterMs =
454
- options.retryAfter === HttpRetryAfterPolicy.Ignore
455
- ? undefined
456
- : headers
457
- ? parseRetryAfterMs(headers)
458
- : undefined;
459
- if (retryAfterMs !== undefined) {
460
- const boundedRetryAfterMs = Math.min(retryAfterMs, options.maxDelayMs);
461
- if (options.retryAfter === HttpRetryAfterPolicy.Cap) {
462
- return Math.min(boundedRetryAfterMs, configuredDelay);
463
- }
464
- return boundedRetryAfterMs;
465
- }
466
-
467
- switch (options.jitter) {
468
- case HttpRetryJitter.None:
469
- return configuredDelay;
470
- case HttpRetryJitter.Equal:
471
- return Math.floor(
472
- configuredDelay / 2 + Math.random() * (configuredDelay / 2),
473
- );
474
- case HttpRetryJitter.Full:
475
- return Math.floor(Math.random() * configuredDelay);
476
- }
67
+ function isDedupeSkipOutcome(outcome: NativeHttpAttemptOutcome): outcome is NativeHttpSkipOutcome {
68
+ return "kind" in outcome && outcome.kind === "dedupe-skip";
477
69
  }
478
70
 
479
71
  async function sleep(ms: number): Promise<void> {
@@ -500,9 +92,7 @@ function withClientHeaders(
500
92
  body: unknown,
501
93
  ): RequestOptions {
502
94
  const headers: Record<string, string> = {
503
- ...(clientOptions.userAgent
504
- ? { "User-Agent": clientOptions.userAgent }
505
- : {}),
95
+ ...(clientOptions.userAgent ? { "User-Agent": clientOptions.userAgent } : {}),
506
96
  ...options?.headers,
507
97
  };
508
98
 
@@ -517,10 +107,7 @@ function withClientHeaders(
517
107
  }
518
108
 
519
109
  function parseHttpData(body: string, headers: Record<string, string>): unknown {
520
- const contentType =
521
- headers["content-type"] ??
522
- headers["Content-Type"] ??
523
- headers["CONTENT-TYPE"];
110
+ const contentType = headers["content-type"] ?? headers["Content-Type"] ?? headers["CONTENT-TYPE"];
524
111
 
525
112
  if (contentType?.includes("application/json")) {
526
113
  return body ? JSON.parse(body) : null;
@@ -582,7 +169,8 @@ function toHttpTransportError(error: unknown): TransportError {
582
169
 
583
170
  async function toNativeHttpResponse(response: Response): Promise<HttpResponse> {
584
171
  const headers = Object.fromEntries(response.headers.entries());
585
- const rawText = await response.text();
172
+ const bodyBytes = new Uint8Array(await response.arrayBuffer());
173
+ const rawText = new TextDecoder().decode(bodyBytes);
586
174
  const data = parseHttpData(rawText, headers);
587
175
 
588
176
  return {
@@ -590,18 +178,15 @@ async function toNativeHttpResponse(response: Response): Promise<HttpResponse> {
590
178
  headers,
591
179
  json: async <T = unknown>() => {
592
180
  const contentType =
593
- headers["content-type"] ??
594
- headers["Content-Type"] ??
595
- headers["CONTENT-TYPE"];
596
- return parseJson<T>(
597
- contentType?.includes("application/json") && !rawText
598
- ? "null"
599
- : rawText,
600
- );
181
+ headers["content-type"] ?? headers["Content-Type"] ?? headers["CONTENT-TYPE"];
182
+ return parseJson<T>(contentType?.includes("application/json") && !rawText ? "null" : rawText);
601
183
  },
602
184
  ok: response.status >= 200 && response.status < 300,
603
185
  status: response.status,
604
186
  text: async () => rawText,
187
+ arrayBuffer: async () =>
188
+ bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength),
189
+ bytes: async () => bodyBytes.slice(0),
605
190
  };
606
191
  }
607
192
 
@@ -613,9 +198,7 @@ async function drainNativeResponseBody(response: Response): Promise<void> {
613
198
  }
614
199
  }
615
200
 
616
- function requireNativeResponseBody(
617
- response: Response,
618
- ): ReadableStream<Uint8Array> {
201
+ function requireNativeResponseBody(response: Response): ReadableStream<Uint8Array> {
619
202
  if (!response.body) {
620
203
  throw new TransportError("Response body stream is unavailable", {
621
204
  code: "transport_stream_unavailable",
@@ -678,12 +261,21 @@ async function resolveNativeProxy(
678
261
  options: RequestOptions,
679
262
  clientOptions: HttpClientOptions,
680
263
  warn: (message: string) => void,
264
+ proxyAttemptOffset = 0,
681
265
  ): Promise<string | undefined> {
682
266
  const resolvedProxy = await resolveProxyConfigAsync({
683
267
  proxy: options.proxy ?? clientOptions.proxy,
684
268
  upstream: clientOptions.upstream,
685
269
  apifuseConfig: clientOptions.apifuseConfig,
270
+ proxyPolicy: clientOptions.proxyPolicy,
686
271
  affinityKey: clientOptions.affinityKey,
272
+ proxyAttempt: computeProxyAttemptIndex({
273
+ baseProxyAttempt: clientOptions.proxyAttempt,
274
+ retryAttemptOffset: proxyAttemptOffset,
275
+ }),
276
+ // Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
277
+ // supported here, so a socks5 policy fails loudly rather than downgrading.
278
+ transportProtocols: ["http"],
687
279
  telemetry: clientOptions.telemetry,
688
280
  });
689
281
  if (resolvedProxy.shouldWarn) {
@@ -703,9 +295,7 @@ function assertNoHttpTransportOverrides(options: RequestOptions): void {
703
295
  }
704
296
  }
705
297
 
706
- function normalizeNativeFetchBody(
707
- body: unknown,
708
- ): string | ArrayBuffer | undefined {
298
+ function normalizeNativeFetchBody(body: unknown): string | ArrayBuffer | undefined {
709
299
  const normalized = normalizeHttpRequestBody(body);
710
300
  if (!Buffer.isBuffer(normalized)) {
711
301
  return normalized;
@@ -723,18 +313,31 @@ async function fetchNativeHttp(
723
313
  clientOptions: HttpClientOptions,
724
314
  warn: (message: string) => void,
725
315
  statusRetryCodes?: readonly number[],
726
- ): Promise<HttpResponse | HttpStatusOutcome> {
727
- const requestUrl = appendQueryParams(
728
- resolveHttpUrl(baseUrl, url),
729
- options.params,
730
- );
316
+ proxyAttemptOffset = 0,
317
+ dedupe?: { attempted: Set<string> },
318
+ ): Promise<NativeHttpAttemptOutcome> {
319
+ const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
731
320
  const controller = options.timeout ? new AbortController() : undefined;
732
321
  const timeoutHandle = options.timeout
733
322
  ? setTimeout(() => controller?.abort(), options.timeout)
734
323
  : undefined;
735
324
 
325
+ let proxy: string | undefined;
736
326
  try {
737
- const proxy = await resolveNativeProxy(options, clientOptions, warn);
327
+ // Resolve inside the try (and after the timeout is armed) so allocator
328
+ // failures are branded as TransportErrors and count against the request
329
+ // deadline, exactly as an inline resolve would.
330
+ proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
331
+ // For a registry allocator chain, skip an endpoint a prior attempt already
332
+ // tried rather than re-issuing the same request. Returning the sentinel
333
+ // (instead of breaking) lets the loop keep advancing the flat offset until
334
+ // it crosses into the fallback vendor's pool span.
335
+ if (dedupe && proxy) {
336
+ if (dedupe.attempted.has(proxy)) {
337
+ return { kind: "dedupe-skip" };
338
+ }
339
+ dedupe.attempted.add(proxy);
340
+ }
738
341
  const requestInit: NativeFetchInit = {
739
342
  headers: options.headers,
740
343
  method,
@@ -756,18 +359,16 @@ async function fetchNativeHttp(
756
359
  status: response.status,
757
360
  headers,
758
361
  retryable: statusRetryCodes.includes(response.status),
362
+ proxyUsed: Boolean(proxy),
759
363
  };
760
364
  }
761
365
 
762
366
  if (response.status >= 400 && options.throwOnHttpError !== false) {
763
367
  await drainNativeResponseBody(response);
764
- throw new TransportError(
765
- `Upstream request failed with status ${response.status}`,
766
- {
767
- code: "upstream_http_error",
768
- status: response.status,
769
- },
770
- );
368
+ throw new TransportError(`Upstream request failed with status ${response.status}`, {
369
+ code: "upstream_http_error",
370
+ status: response.status,
371
+ });
771
372
  }
772
373
 
773
374
  return toNativeHttpResponse(response);
@@ -775,7 +376,9 @@ async function fetchNativeHttp(
775
376
  if (error instanceof SyntaxError) {
776
377
  throw error;
777
378
  }
778
- throw toHttpTransportError(error);
379
+ const transportError = toHttpTransportError(error) as NativeHttpAttemptError;
380
+ transportError.proxyUsed = Boolean(proxy);
381
+ throw transportError;
779
382
  } finally {
780
383
  if (timeoutHandle) clearTimeout(timeoutHandle);
781
384
  }
@@ -789,10 +392,7 @@ async function fetchNativeHttpStream(
789
392
  clientOptions: HttpClientOptions,
790
393
  warn: (message: string) => void,
791
394
  ): Promise<HttpStreamResponse> {
792
- const requestUrl = appendQueryParams(
793
- resolveHttpUrl(baseUrl, url),
794
- options.params,
795
- );
395
+ const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
796
396
  const controller = options.timeout ? new AbortController() : undefined;
797
397
  const timeoutHandle = options.timeout
798
398
  ? setTimeout(() => controller?.abort(), options.timeout)
@@ -815,13 +415,10 @@ async function fetchNativeHttpStream(
815
415
 
816
416
  if (response.status >= 400 && options.throwOnHttpError !== false) {
817
417
  await drainNativeResponseBody(response);
818
- throw new TransportError(
819
- `Upstream request failed with status ${response.status}`,
820
- {
821
- code: "upstream_http_error",
822
- status: response.status,
823
- },
824
- );
418
+ throw new TransportError(`Upstream request failed with status ${response.status}`, {
419
+ code: "upstream_http_error",
420
+ status: response.status,
421
+ });
825
422
  }
826
423
 
827
424
  return toNativeHttpStreamResponse(response);
@@ -861,31 +458,84 @@ export function createHttpClient(
861
458
  );
862
459
  }
863
460
  assertNoHttpTransportOverrides(options);
864
- const headersOptions = withClientHeaders(
865
- options,
866
- clientOptions,
867
- options.body,
868
- );
461
+ const headersOptions = withClientHeaders(options, clientOptions, options.body);
869
462
  const methodName = normalizeHttpMethod(method);
870
- const retryOptions = normalizeRetryOptions(headersOptions.retry);
871
- if (retryOptions) validateUnsafeRetryMethods(retryOptions);
463
+ const explicitRetry = headersOptions.retry !== undefined;
464
+ const retryOptions =
465
+ normalizeProxyTransportRetryOptions(headersOptions.retry, {
466
+ label: "HTTP",
467
+ }) ??
468
+ (explicitRetry ? undefined : createDefaultProxyTransportRetryOptions({ label: "HTTP" }));
469
+ if (retryOptions) validateUnsafeProxyTransportRetryMethods(retryOptions, "HTTP");
872
470
  const retryEnabled = Boolean(
873
471
  retryOptions &&
874
472
  retryOptions.attempts > 1 &&
875
- isMethodRetryable(methodName, retryOptions),
473
+ isProxyTransportRetryMethod(methodName, retryOptions),
876
474
  );
877
475
  const statusRetryEnabled = Boolean(
878
476
  retryEnabled &&
477
+ explicitRetry &&
879
478
  retryOptions &&
880
479
  retryOptions.statusCodes.length > 0 &&
881
480
  headersOptions.throwOnHttpError !== false,
882
481
  );
883
- const attemptOptions: RequestOptions & { body?: unknown } =
884
- statusRetryEnabled
885
- ? { ...headersOptions, throwOnHttpError: false }
886
- : headersOptions;
482
+ const attemptOptions: RequestOptions & { body?: unknown } = statusRetryEnabled
483
+ ? { ...headersOptions, throwOnHttpError: false }
484
+ : headersOptions;
485
+
486
+ // Span the whole vendor chain on transport failures. Like ctx.stealth, a
487
+ // policy-managed proxy resolves a *different* endpoint/vendor per attempt
488
+ // (the flat proxyAttemptOffset rotates across the concatenated vendor pool
489
+ // spans), so a transport failure should advance to the next endpoint —
490
+ // potentially crossing into the fallback vendor — rather than stopping at
491
+ // the per-endpoint retry budget and stranding the request on the primary
492
+ // vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
493
+ // only for implicit, safe-method allocator requests; explicit retry
494
+ // policies (their documented `attempts` ceiling), unsafe methods, and
495
+ // static/non-registry vendors keep the retry budget. Status-code retries
496
+ // stay bounded by the retry budget regardless; only transport rotation gets
497
+ // the full span.
498
+ const policyProxy: ProviderProxyPolicy | undefined = (() => {
499
+ const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
500
+ return policy && typeof policy === "object" ? policy : undefined;
501
+ })();
502
+ const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
503
+ const transportAttemptCap = retryOptions
504
+ ? resolvePolicyTransportAttemptCap({
505
+ policy: policyProxy,
506
+ usesPolicyAllocator,
507
+ retryAttempts: retryOptions.attempts,
508
+ explicitRetry,
509
+ method: methodName,
510
+ })
511
+ : 1;
887
512
 
888
- const executeOnce = (): Promise<HttpResponse | HttpStatusOutcome> =>
513
+ // Track resolved endpoints across a policy-allocator chain. Successive
514
+ // attempts rotate the flat offset across the concatenated vendor pool
515
+ // spans, but an under-filled allocation (fewer live endpoints than the
516
+ // configured pool size) makes the modulo mapping repeat endpoints before
517
+ // the offset reaches the next vendor. Rather than re-hammering an
518
+ // already-tried endpoint under backoff, fetchNativeHttp returns a skip
519
+ // sentinel for a duplicate; the loop then advances the flat offset without
520
+ // issuing the request, so it keeps walking toward — and into — the fallback
521
+ // vendor's pool span instead of stalling on the primary vendor.
522
+ // De-duplication is gated on the SAME predicate that widens the attempt cap
523
+ // (implicit, safe-method, registry-chain rotation). It must NOT engage for
524
+ // an explicit retry policy: there the caller's `attempts` count is the
525
+ // contract and each attempt must issue against whatever endpoint it resolves
526
+ // — even a repeat — instead of being silently skipped (which would collapse
527
+ // a `poolSize: 1` + `attempts: 3` request to a single fetch).
528
+ const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
529
+ policy: policyProxy,
530
+ usesPolicyAllocator,
531
+ explicitRetry,
532
+ method: methodName,
533
+ });
534
+ const dedupeContext = dedupeAllocatorEndpoints
535
+ ? { attempted: new Set<string>() }
536
+ : undefined;
537
+
538
+ const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
889
539
  fetchNativeHttp(
890
540
  baseUrl,
891
541
  url,
@@ -894,44 +544,68 @@ export function createHttpClient(
894
544
  clientOptions,
895
545
  warnOnce,
896
546
  statusRetryEnabled ? retryOptions?.statusCodes : undefined,
547
+ proxyAttemptOffset,
548
+ dedupeContext,
897
549
  );
898
550
 
899
551
  if (!retryEnabled || !retryOptions) {
900
552
  const outcome = await executeOnce();
553
+ if (isDedupeSkipOutcome(outcome)) {
554
+ // Single-shot path never de-duplicates (dedupeContext is undefined),
555
+ // but keep the union total.
556
+ throw new TransportError("HTTP request produced no terminal result", {
557
+ code: "retry_exhausted",
558
+ });
559
+ }
901
560
  if (isHttpStatusOutcome(outcome)) {
902
561
  throw toUpstreamHttpError(outcome.status);
903
562
  }
904
563
  return outcome;
905
564
  }
906
565
 
566
+ let lastError: unknown;
907
567
  let lastErrorCode: string | undefined;
908
568
  let lastStatus: number | undefined;
909
- for (let attempt = 1; attempt <= retryOptions.attempts; attempt += 1) {
569
+ // `attempt` walks the flat proxy offset across the full chain span; `issued`
570
+ // counts requests that were actually sent (skipped duplicate offsets do not
571
+ // increment it). Retry summaries and the status-retry budget must reflect
572
+ // issued requests, not the raw offset, so they stay accurate when partial
573
+ // allocations skip offsets.
574
+ let issued = 0;
575
+ for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
576
+ // Whether this offset actually issued a request (vs. a skipped duplicate),
577
+ // so the catch counts a thrown *transport* failure once without
578
+ // double-counting a status outcome that already incremented before it
579
+ // re-threw as an upstream HTTP error.
580
+ let issuedThisAttempt = false;
910
581
  try {
911
- const outcome = await executeOnce();
582
+ const outcome = await executeOnce(attempt - 1);
583
+ if (isDedupeSkipOutcome(outcome)) {
584
+ // Duplicate endpoint from a partial allocation: advance the flat
585
+ // offset without issuing the request (no backoff, not a failure) so
586
+ // the loop keeps rotating toward the fallback vendor.
587
+ continue;
588
+ }
589
+ issued += 1;
590
+ issuedThisAttempt = true;
912
591
  if (isHttpStatusOutcome(outcome)) {
913
592
  lastStatus = outcome.status;
914
- if (outcome.retryable && attempt < retryOptions.attempts) {
915
- await sleep(
916
- computeRetryDelayMs(retryOptions, attempt, outcome.headers),
917
- );
593
+ if (outcome.retryable && issued < retryOptions.attempts) {
594
+ await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
918
595
  continue;
919
596
  }
920
597
  throw toUpstreamHttpError(outcome.status);
921
598
  }
922
599
 
923
600
  const response = outcome;
924
- if (
925
- response.status >= 400 &&
926
- headersOptions.throwOnHttpError !== false
927
- ) {
601
+ if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
928
602
  throw toUpstreamHttpError(response.status);
929
603
  }
930
604
 
931
- if (attempt > 1) {
605
+ if (issued > 1) {
932
606
  const summary: HttpRetrySummary = {
933
- attempts: attempt,
934
- retries: attempt - 1,
607
+ attempts: issued,
608
+ retries: issued - 1,
935
609
  ...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
936
610
  transport: "native",
937
611
  ...(lastErrorCode ? { lastErrorCode } : {}),
@@ -941,19 +615,35 @@ export function createHttpClient(
941
615
  }
942
616
  return response;
943
617
  } catch (error) {
944
- lastErrorCode = retryErrorCode(error);
945
- lastStatus = retryErrorStatus(error);
618
+ if (!issuedThisAttempt) issued += 1;
619
+ lastError = error;
620
+ lastErrorCode = proxyTransportRetryErrorCode(error);
621
+ lastStatus = proxyTransportRetryErrorStatus(error);
622
+ const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
946
623
  if (
947
- attempt < retryOptions.attempts &&
948
- shouldRetryTransportError(error, retryOptions)
624
+ attempt < transportAttemptCap &&
625
+ shouldRetryProxyTransportAttempt({
626
+ error,
627
+ explicitRetry,
628
+ method: methodName,
629
+ options: retryOptions,
630
+ proxyUsed,
631
+ })
949
632
  ) {
950
- await sleep(computeRetryDelayMs(retryOptions, attempt));
633
+ await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt));
951
634
  continue;
952
635
  }
953
636
  throw error;
954
637
  }
955
638
  }
956
639
 
640
+ // Reached when the attempt cap is consumed without a terminal outcome —
641
+ // e.g. the final offsets of a partial allocation all resolved to
642
+ // already-tried endpoints and were skipped. Surface the last real transport
643
+ // failure rather than a synthetic exhaustion error.
644
+ if (lastError !== undefined) {
645
+ throw lastError;
646
+ }
957
647
  throw new TransportError("HTTP retry exhausted without a terminal result", {
958
648
  code: "retry_exhausted",
959
649
  });
@@ -971,30 +661,17 @@ export function createHttpClient(
971
661
  );
972
662
  }
973
663
  assertNoHttpTransportOverrides(options);
974
- const headersOptions = withClientHeaders(
975
- options,
976
- clientOptions,
977
- options.body,
978
- );
664
+ const headersOptions = withClientHeaders(options, clientOptions, options.body);
979
665
  const methodName = normalizeHttpMethod(method);
980
- return fetchNativeHttpStream(
981
- baseUrl,
982
- url,
983
- methodName,
984
- headersOptions,
985
- clientOptions,
986
- warnOnce,
987
- );
666
+ return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
988
667
  }
989
668
 
990
669
  return {
991
670
  request: async (url, options: RequestWithMethodOptions = {}) =>
992
671
  request(url, options.method ?? "GET", options),
993
672
  get: async (url, options) => request(url, "GET", options),
994
- post: async (url, body, options) =>
995
- request(url, "POST", { ...options, body }),
996
- put: async (url, body, options) =>
997
- request(url, "PUT", { ...options, body }),
673
+ post: async (url, body, options) => request(url, "POST", { ...options, body }),
674
+ put: async (url, body, options) => request(url, "PUT", { ...options, body }),
998
675
  delete: async (url, options) => request(url, "DELETE", options),
999
676
  stream: async (url, options: RequestWithMethodOptions = {}) =>
1000
677
  streamRequest(url, options.method ?? "GET", options),