@apifuse/provider-sdk 2.2.0-beta.1 → 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 (203) hide show
  1. package/AUTHORING.md +106 -0
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +5 -1
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +14 -0
  9. package/bin/apifuse-pack-smoke.ts +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 +510 -45
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth.d.ts +2 -2
  21. package/dist/auth.js +9 -18
  22. package/dist/ceremonies/index.d.ts +1 -1
  23. package/dist/ceremonies/index.js +8 -18
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/config/loader.d.ts +149 -8
  32. package/dist/config/loader.js +378 -89
  33. package/dist/contract-serialization.d.ts +2 -2
  34. package/dist/contract-serialization.js +3 -6
  35. package/dist/contract-types.d.ts +2 -2
  36. package/dist/contract.d.ts +3 -3
  37. package/dist/contract.js +4 -6
  38. package/dist/define.d.ts +8 -1
  39. package/dist/define.js +119 -122
  40. package/dist/dev.d.ts +1 -1
  41. package/dist/dev.js +1 -1
  42. package/dist/errors.d.ts +4 -1
  43. package/dist/errors.js +48 -0
  44. package/dist/i18n/catalog.d.ts +2 -2
  45. package/dist/i18n/catalog.js +4 -10
  46. package/dist/i18n/index.d.ts +2 -2
  47. package/dist/i18n/index.js +2 -2
  48. package/dist/i18n/keys.d.ts +2 -2
  49. package/dist/index.d.ts +44 -42
  50. package/dist/index.js +39 -37
  51. package/dist/lint.js +8 -15
  52. package/dist/provider.d.ts +11 -11
  53. package/dist/provider.js +9 -9
  54. package/dist/public-schema-field-lint.d.ts +1 -1
  55. package/dist/recipes/gov-api.js +1 -1
  56. package/dist/runtime/auth-flow.d.ts +1 -1
  57. package/dist/runtime/auth-flow.js +3 -3
  58. package/dist/runtime/browser.d.ts +1 -1
  59. package/dist/runtime/browser.js +15 -29
  60. package/dist/runtime/cache.d.ts +1 -1
  61. package/dist/runtime/cache.js +4 -8
  62. package/dist/runtime/choice.d.ts +1 -1
  63. package/dist/runtime/choice.js +31 -35
  64. package/dist/runtime/credential.d.ts +1 -1
  65. package/dist/runtime/credential.js +1 -1
  66. package/dist/runtime/env.d.ts +1 -1
  67. package/dist/runtime/executor.d.ts +1 -1
  68. package/dist/runtime/executor.js +15 -3
  69. package/dist/runtime/http.d.ts +2 -2
  70. package/dist/runtime/http.js +144 -348
  71. package/dist/runtime/insights.d.ts +1 -1
  72. package/dist/runtime/insights.js +6 -13
  73. package/dist/runtime/instrumentation.d.ts +2 -2
  74. package/dist/runtime/instrumentation.js +56 -19
  75. package/dist/runtime/keyring.js +1 -1
  76. package/dist/runtime/namespace.js +1 -1
  77. package/dist/runtime/otlp.d.ts +1 -1
  78. package/dist/runtime/perf.d.ts +1 -1
  79. package/dist/runtime/provider.d.ts +1 -1
  80. package/dist/runtime/provider.js +1 -2
  81. package/dist/runtime/proxy-errors.d.ts +1 -1
  82. package/dist/runtime/proxy-errors.js +9 -7
  83. package/dist/runtime/proxy-nodemaven.d.ts +35 -0
  84. package/dist/runtime/proxy-nodemaven.js +128 -0
  85. package/dist/runtime/proxy-retry-policy.d.ts +40 -0
  86. package/dist/runtime/proxy-retry-policy.js +326 -0
  87. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  88. package/dist/runtime/proxy-telemetry.js +55 -52
  89. package/dist/runtime/redis.d.ts +1 -1
  90. package/dist/runtime/redis.js +2 -4
  91. package/dist/runtime/request-options.d.ts +1 -1
  92. package/dist/runtime/secrets.d.ts +27 -0
  93. package/dist/runtime/secrets.js +51 -0
  94. package/dist/runtime/state.d.ts +2 -2
  95. package/dist/runtime/state.js +15 -4
  96. package/dist/runtime/stealth.d.ts +2 -2
  97. package/dist/runtime/stealth.js +108 -212
  98. package/dist/runtime/stt.d.ts +1 -1
  99. package/dist/runtime/stt.js +11 -15
  100. package/dist/runtime/trace.d.ts +2 -2
  101. package/dist/runtime/trace.js +2 -4
  102. package/dist/runtime/waterfall.d.ts +1 -1
  103. package/dist/schema.d.ts +1 -1
  104. package/dist/schema.js +7 -15
  105. package/dist/serve.d.ts +1 -1
  106. package/dist/serve.js +1 -1
  107. package/dist/server/index.d.ts +7 -7
  108. package/dist/server/index.js +6 -6
  109. package/dist/server/self-test-redaction.d.ts +1 -1
  110. package/dist/server/self-test-redaction.js +1 -1
  111. package/dist/server/self-test.d.ts +104 -3
  112. package/dist/server/self-test.js +673 -115
  113. package/dist/server/serve.d.ts +8 -3
  114. package/dist/server/serve.js +105 -59
  115. package/dist/server/types.d.ts +10 -9
  116. package/dist/server/types.js +3 -7
  117. package/dist/stealth/profiles.d.ts +1 -1
  118. package/dist/stealth/profiles.js +5 -14
  119. package/dist/stream.d.ts +1 -1
  120. package/dist/testing/index.d.ts +2 -2
  121. package/dist/testing/index.js +2 -2
  122. package/dist/testing/run.d.ts +1 -1
  123. package/dist/testing/run.js +7 -14
  124. package/dist/types.d.ts +87 -1
  125. package/dist/user-input.d.ts +30 -0
  126. package/dist/user-input.js +66 -0
  127. package/package.json +9 -5
  128. package/src/auth-turn/index.ts +1 -1
  129. package/src/auth.ts +28 -86
  130. package/src/ceremonies/index.ts +24 -78
  131. package/src/cli/commands.ts +10 -0
  132. package/src/cli/create.ts +42 -35
  133. package/src/cli/prompt-assets.ts +865 -0
  134. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  135. package/src/config/loader.ts +543 -208
  136. package/src/contract-serialization.ts +5 -11
  137. package/src/contract-types.ts +2 -2
  138. package/src/contract.ts +12 -28
  139. package/src/define.ts +247 -505
  140. package/src/dev.ts +4 -9
  141. package/src/errors.ts +58 -5
  142. package/src/i18n/catalog.ts +10 -32
  143. package/src/i18n/index.ts +2 -2
  144. package/src/i18n/keys.ts +5 -11
  145. package/src/index.ts +49 -42
  146. package/src/lint.ts +89 -159
  147. package/src/provider.ts +15 -11
  148. package/src/public-schema-field-lint.ts +7 -33
  149. package/src/recipes/gov-api.ts +2 -5
  150. package/src/runtime/auth-flow.ts +5 -7
  151. package/src/runtime/browser.ts +64 -187
  152. package/src/runtime/cache.ts +20 -67
  153. package/src/runtime/choice.ts +79 -132
  154. package/src/runtime/credential.ts +2 -2
  155. package/src/runtime/env.ts +1 -1
  156. package/src/runtime/executor.ts +23 -20
  157. package/src/runtime/http.ts +195 -559
  158. package/src/runtime/insights.ts +15 -53
  159. package/src/runtime/instrumentation.ts +78 -65
  160. package/src/runtime/keyring.ts +7 -19
  161. package/src/runtime/namespace.ts +2 -7
  162. package/src/runtime/otlp.ts +12 -23
  163. package/src/runtime/perf.ts +1 -1
  164. package/src/runtime/provider.ts +4 -9
  165. package/src/runtime/proxy-errors.ts +29 -42
  166. package/src/runtime/proxy-nodemaven.ts +178 -0
  167. package/src/runtime/proxy-retry-policy.ts +469 -0
  168. package/src/runtime/proxy-telemetry.ts +79 -77
  169. package/src/runtime/redis.ts +4 -12
  170. package/src/runtime/request-options.ts +4 -13
  171. package/src/runtime/secrets.ts +64 -0
  172. package/src/runtime/state.ts +41 -110
  173. package/src/runtime/stealth.ts +162 -377
  174. package/src/runtime/stt.ts +38 -94
  175. package/src/runtime/trace.ts +14 -44
  176. package/src/runtime/waterfall.ts +5 -18
  177. package/src/schema.ts +23 -84
  178. package/src/serve.ts +1 -1
  179. package/src/server/index.ts +12 -7
  180. package/src/server/self-test-redaction.ts +2 -2
  181. package/src/server/self-test.ts +857 -132
  182. package/src/server/serve.ts +172 -245
  183. package/src/server/types.ts +7 -19
  184. package/src/stealth/profiles.ts +10 -26
  185. package/src/stream.ts +8 -19
  186. package/src/testing/index.ts +2 -2
  187. package/src/testing/run.ts +19 -67
  188. package/src/types.ts +88 -1
  189. package/src/user-input.ts +118 -0
  190. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  191. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  192. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  193. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  194. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  195. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  196. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  197. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  198. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  199. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  200. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  201. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  202. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  203. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -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,24 +38,6 @@ 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;
@@ -61,424 +46,26 @@ type HttpStatusOutcome = {
61
46
  proxyUsed: boolean;
62
47
  };
63
48
 
64
- type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome;
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" };
58
+
59
+ type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome | NativeHttpSkipOutcome;
65
60
 
66
61
  type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
67
62
 
68
- function isHttpStatusOutcome(
69
- outcome: HttpResponse | HttpStatusOutcome,
70
- ): outcome is HttpStatusOutcome {
63
+ function isHttpStatusOutcome(outcome: NativeHttpAttemptOutcome): outcome is HttpStatusOutcome {
71
64
  return "kind" in outcome && outcome.kind === "http-status";
72
65
  }
73
66
 
74
- const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS"] as const;
75
- const DEFAULT_RETRY_ERROR_CODES = [
76
- "transport_network_error",
77
- "transport_timeout",
78
- ] as const;
79
- const SAFE_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504] as const;
80
- const RATE_LIMIT_RETRY_STATUS_CODES = [429, 503] as const;
81
- const KNOWN_RETRY_METHODS = new Set([
82
- "GET",
83
- "HEAD",
84
- "POST",
85
- "PUT",
86
- "DELETE",
87
- "OPTIONS",
88
- "TRACE",
89
- "PATCH",
90
- ]);
91
- const UNSAFE_RETRY_METHODS = new Set([
92
- "POST",
93
- "PUT",
94
- "PATCH",
95
- "DELETE",
96
- "TRACE",
97
- ]);
98
- const MAX_RETRY_ATTEMPTS = 8;
99
- const MAX_RETRY_DELAY_MS = 30_000;
100
-
101
- function hasOwnValue<T extends string>(
102
- values: Record<string, T>,
103
- value: unknown,
104
- ): value is T {
105
- if (typeof value !== "string") return false;
106
- return Object.values(values).some((candidate) => candidate === value);
107
- }
108
-
109
- function createInvalidRetryPolicyError(message: string): ProviderError {
110
- return new ProviderError(message, { code: "retry_invalid_policy" });
111
- }
112
-
113
- function createRetryOptions(preset: HttpRetryPreset): NormalizedRetryOptions {
114
- switch (preset) {
115
- case HttpRetryPreset.Off:
116
- return {
117
- preset,
118
- attempts: 1,
119
- methods: DEFAULT_RETRY_METHODS,
120
- statusCodes: [],
121
- errorCodes: DEFAULT_RETRY_ERROR_CODES,
122
- delayStrategy: HttpRetryDelayStrategy.Exponential,
123
- baseDelayMs: 100,
124
- maxDelayMs: 1_000,
125
- jitter: HttpRetryJitter.Full,
126
- retryAfter: HttpRetryAfterPolicy.Ignore,
127
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
128
- };
129
- case HttpRetryPreset.SafeRead:
130
- return {
131
- preset,
132
- attempts: 3,
133
- methods: DEFAULT_RETRY_METHODS,
134
- statusCodes: SAFE_RETRY_STATUS_CODES,
135
- errorCodes: DEFAULT_RETRY_ERROR_CODES,
136
- delayStrategy: HttpRetryDelayStrategy.Exponential,
137
- baseDelayMs: 100,
138
- maxDelayMs: 2_000,
139
- jitter: HttpRetryJitter.Full,
140
- retryAfter: HttpRetryAfterPolicy.Cap,
141
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
142
- };
143
- case HttpRetryPreset.AggressiveRead:
144
- return {
145
- preset,
146
- attempts: 4,
147
- methods: DEFAULT_RETRY_METHODS,
148
- statusCodes: SAFE_RETRY_STATUS_CODES,
149
- errorCodes: DEFAULT_RETRY_ERROR_CODES,
150
- delayStrategy: HttpRetryDelayStrategy.Exponential,
151
- baseDelayMs: 150,
152
- maxDelayMs: 5_000,
153
- jitter: HttpRetryJitter.Full,
154
- retryAfter: HttpRetryAfterPolicy.Cap,
155
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
156
- };
157
- case HttpRetryPreset.RateLimitAware:
158
- return {
159
- preset,
160
- attempts: 3,
161
- methods: DEFAULT_RETRY_METHODS,
162
- statusCodes: RATE_LIMIT_RETRY_STATUS_CODES,
163
- errorCodes: ["transport_timeout"],
164
- delayStrategy: HttpRetryDelayStrategy.Exponential,
165
- baseDelayMs: 250,
166
- maxDelayMs: 5_000,
167
- jitter: HttpRetryJitter.Equal,
168
- retryAfter: HttpRetryAfterPolicy.Respect,
169
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
170
- };
171
- case HttpRetryPreset.TransportTransient:
172
- return {
173
- preset,
174
- attempts: 3,
175
- methods: DEFAULT_RETRY_METHODS,
176
- statusCodes: [],
177
- errorCodes: DEFAULT_RETRY_ERROR_CODES,
178
- delayStrategy: HttpRetryDelayStrategy.Exponential,
179
- baseDelayMs: 100,
180
- maxDelayMs: 1_000,
181
- jitter: HttpRetryJitter.Full,
182
- retryAfter: HttpRetryAfterPolicy.Ignore,
183
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
184
- };
185
- }
186
- throw createInvalidRetryPolicyError(`Unknown HTTP retry preset: ${preset}`);
187
- }
188
-
189
- function clampPositiveInteger(
190
- value: number | undefined,
191
- fallback: number,
192
- max: number,
193
- ): number {
194
- if (value === undefined) return fallback;
195
- if (!Number.isFinite(value) || value < 1) return fallback;
196
- return Math.min(Math.floor(value), max);
197
- }
198
-
199
- function clampDelay(value: number | undefined, fallback: number): number {
200
- if (value === undefined) return fallback;
201
- if (!Number.isFinite(value) || value < 0) return fallback;
202
- return Math.min(Math.floor(value), MAX_RETRY_DELAY_MS);
203
- }
204
-
205
- function normalizeRetryOptions(
206
- retry: RequestOptions["retry"],
207
- ): NormalizedRetryOptions | undefined {
208
- if (retry === undefined || retry === false) {
209
- return undefined;
210
- }
211
- if (retry === true) {
212
- return createRetryOptions(HttpRetryPreset.TransportTransient);
213
- }
214
- if (typeof retry === "string") {
215
- if (!hasOwnValue(HttpRetryPreset, retry)) {
216
- throw createInvalidRetryPolicyError(
217
- `Unknown HTTP retry preset: ${retry}`,
218
- );
219
- }
220
- return createRetryOptions(retry);
221
- }
222
- if (typeof retry !== "object" || retry === null) {
223
- throw createInvalidRetryPolicyError("HTTP retry policy must be an object");
224
- }
225
- if (Array.isArray(retry)) {
226
- throw createInvalidRetryPolicyError(
227
- "HTTP retry policy must be a plain object",
228
- );
229
- }
230
-
231
- validateRetryOptionsShape(retry);
232
- const base = createRetryOptions(
233
- retry.preset ?? HttpRetryPreset.TransportTransient,
234
- );
235
- const maxDelayMs = clampDelay(retry.maxDelayMs, base.maxDelayMs);
236
- const normalized: NormalizedRetryOptions = {
237
- preset: retry.preset ?? base.preset,
238
- attempts: clampPositiveInteger(
239
- retry.attempts,
240
- base.attempts,
241
- MAX_RETRY_ATTEMPTS,
242
- ),
243
- methods:
244
- retry.methods?.map((method) => method.toUpperCase()) ?? base.methods,
245
- statusCodes:
246
- retry.statusCodes?.filter((status) => Number.isInteger(status)) ??
247
- base.statusCodes,
248
- errorCodes: retry.errorCodes ?? base.errorCodes,
249
- delayStrategy: retry.delayStrategy ?? base.delayStrategy,
250
- baseDelayMs: clampDelay(retry.baseDelayMs, base.baseDelayMs),
251
- maxDelayMs,
252
- jitter: retry.jitter ?? base.jitter,
253
- retryAfter: retry.retryAfter ?? base.retryAfter,
254
- unsafeMethodPolicy: retry.unsafeMethodPolicy ?? base.unsafeMethodPolicy,
255
- };
256
-
257
- return normalized;
258
- }
259
-
260
- function validateRetryOptionsShape(retry: HttpRetryOptions): void {
261
- if (
262
- retry.preset !== undefined &&
263
- !hasOwnValue(HttpRetryPreset, retry.preset)
264
- ) {
265
- throw createInvalidRetryPolicyError(
266
- `Unknown HTTP retry preset: ${String(retry.preset)}`,
267
- );
268
- }
269
- if (
270
- retry.delayStrategy !== undefined &&
271
- !hasOwnValue(HttpRetryDelayStrategy, retry.delayStrategy)
272
- ) {
273
- throw createInvalidRetryPolicyError(
274
- `Unknown HTTP retry delay strategy: ${String(retry.delayStrategy)}`,
275
- );
276
- }
277
- if (
278
- retry.jitter !== undefined &&
279
- !hasOwnValue(HttpRetryJitter, retry.jitter)
280
- ) {
281
- throw createInvalidRetryPolicyError(
282
- `Unknown HTTP retry jitter policy: ${String(retry.jitter)}`,
283
- );
284
- }
285
- if (
286
- retry.retryAfter !== undefined &&
287
- !hasOwnValue(HttpRetryAfterPolicy, retry.retryAfter)
288
- ) {
289
- throw createInvalidRetryPolicyError(
290
- `Unknown HTTP retry-after policy: ${String(retry.retryAfter)}`,
291
- );
292
- }
293
- if (
294
- retry.unsafeMethodPolicy !== undefined &&
295
- !hasOwnValue(HttpRetryUnsafeMethodPolicy, retry.unsafeMethodPolicy)
296
- ) {
297
- throw createInvalidRetryPolicyError(
298
- `Unknown HTTP retry unsafe method policy: ${String(retry.unsafeMethodPolicy)}`,
299
- );
300
- }
301
- if (retry.methods !== undefined) {
302
- if (!Array.isArray(retry.methods)) {
303
- throw createInvalidRetryPolicyError(
304
- "HTTP retry methods must be an array",
305
- );
306
- }
307
- const nonStringMethods = retry.methods.filter(
308
- (method) => typeof method !== "string",
309
- );
310
- if (nonStringMethods.length > 0) {
311
- throw createInvalidRetryPolicyError(
312
- "HTTP retry methods must contain only strings",
313
- );
314
- }
315
- const unknownMethods = retry.methods
316
- .map((method) => method.toUpperCase())
317
- .filter((method) => !KNOWN_RETRY_METHODS.has(method));
318
- if (unknownMethods.length > 0) {
319
- throw createInvalidRetryPolicyError(
320
- `Unknown HTTP retry method(s): ${unknownMethods.join(", ")}`,
321
- );
322
- }
323
- }
324
- if (retry.statusCodes !== undefined) {
325
- if (!Array.isArray(retry.statusCodes)) {
326
- throw createInvalidRetryPolicyError(
327
- "HTTP retry statusCodes must be an array",
328
- );
329
- }
330
- const invalidStatusCodes = retry.statusCodes.filter(
331
- (status) =>
332
- !Number.isInteger(status) ||
333
- Number(status) < 100 ||
334
- Number(status) > 599,
335
- );
336
- if (invalidStatusCodes.length > 0) {
337
- throw createInvalidRetryPolicyError(
338
- "HTTP retry statusCodes must contain HTTP status integers in [100, 599]",
339
- );
340
- }
341
- }
342
- if (retry.errorCodes !== undefined) {
343
- if (!Array.isArray(retry.errorCodes)) {
344
- throw createInvalidRetryPolicyError(
345
- "HTTP retry errorCodes must be an array",
346
- );
347
- }
348
- const nonStringErrorCodes = retry.errorCodes.filter(
349
- (errorCode) => typeof errorCode !== "string",
350
- );
351
- if (nonStringErrorCodes.length > 0) {
352
- throw createInvalidRetryPolicyError(
353
- "HTTP retry errorCodes must contain only strings",
354
- );
355
- }
356
- }
357
- if (
358
- retry.preset === HttpRetryPreset.Off &&
359
- ((retry.attempts !== undefined && retry.attempts > 1) ||
360
- (retry.statusCodes !== undefined && retry.statusCodes.length > 0))
361
- ) {
362
- throw createInvalidRetryPolicyError(
363
- "HTTP retry preset off cannot be combined with retry-enabling overrides",
364
- );
365
- }
366
- }
367
-
368
- function validateUnsafeRetryMethods(options: NormalizedRetryOptions): void {
369
- if (
370
- options.unsafeMethodPolicy ===
371
- HttpRetryUnsafeMethodPolicy.AllowExplicitUnsafe
372
- ) {
373
- return;
374
- }
375
- const unsafeMethods = options.methods.filter((method) =>
376
- UNSAFE_RETRY_METHODS.has(method.toUpperCase()),
377
- );
378
- if (unsafeMethods.length === 0) return;
379
-
380
- throw new ProviderError(
381
- `HTTP retry methods include unsafe method(s): ${unsafeMethods.join(", ")}`,
382
- { code: "retry_unsafe_method" },
383
- );
384
- }
385
-
386
- function isMethodRetryable(
387
- method: HttpMethod,
388
- options: NormalizedRetryOptions,
389
- ): boolean {
390
- return options.methods
391
- .map((allowedMethod) => allowedMethod.toUpperCase())
392
- .includes(method.toUpperCase());
393
- }
394
-
395
- function retryErrorCode(error: unknown): string | undefined {
396
- if (error instanceof TransportError) {
397
- return error.code;
398
- }
399
- if (error && typeof error === "object" && "code" in error) {
400
- const code = Reflect.get(error, "code");
401
- return typeof code === "string" ? code : undefined;
402
- }
403
- return undefined;
404
- }
405
-
406
- function retryErrorStatus(error: unknown): number | undefined {
407
- if (error instanceof TransportError) {
408
- return error.status ?? error.upstreamStatus;
409
- }
410
- return undefined;
411
- }
412
-
413
- function shouldRetryTransportError(
414
- error: unknown,
415
- options: NormalizedRetryOptions,
416
- ): boolean {
417
- const code = retryErrorCode(error);
418
- return Boolean(code && options.errorCodes.includes(code));
419
- }
420
-
421
- function retryAfterHeader(headers: Record<string, string>): string | undefined {
422
- for (const [name, value] of Object.entries(headers)) {
423
- if (name.toLowerCase() === "retry-after") return value;
424
- }
425
- return undefined;
426
- }
427
-
428
- function parseRetryAfterMs(
429
- headers: Record<string, string>,
430
- now: number = Date.now(),
431
- ): number | undefined {
432
- const value = retryAfterHeader(headers);
433
- if (!value) return undefined;
434
- const seconds = Number(value);
435
- if (Number.isFinite(seconds)) {
436
- return Math.max(0, Math.floor(seconds * 1_000));
437
- }
438
- const dateMs = Date.parse(value);
439
- if (!Number.isNaN(dateMs)) {
440
- return Math.max(0, dateMs - now);
441
- }
442
- return undefined;
443
- }
444
-
445
- function computeRetryDelayMs(
446
- options: NormalizedRetryOptions,
447
- attemptIndex: number,
448
- headers?: Record<string, string>,
449
- ): number {
450
- const multiplier =
451
- options.delayStrategy === HttpRetryDelayStrategy.Exponential
452
- ? 2 ** Math.max(0, attemptIndex - 1)
453
- : 1;
454
- const configuredDelay = Math.min(
455
- options.baseDelayMs * multiplier,
456
- options.maxDelayMs,
457
- );
458
- const retryAfterMs =
459
- options.retryAfter === HttpRetryAfterPolicy.Ignore
460
- ? undefined
461
- : headers
462
- ? parseRetryAfterMs(headers)
463
- : undefined;
464
- if (retryAfterMs !== undefined) {
465
- const boundedRetryAfterMs = Math.min(retryAfterMs, options.maxDelayMs);
466
- if (options.retryAfter === HttpRetryAfterPolicy.Cap) {
467
- return Math.min(boundedRetryAfterMs, configuredDelay);
468
- }
469
- return boundedRetryAfterMs;
470
- }
471
-
472
- switch (options.jitter) {
473
- case HttpRetryJitter.None:
474
- return configuredDelay;
475
- case HttpRetryJitter.Equal:
476
- return Math.floor(
477
- configuredDelay / 2 + Math.random() * (configuredDelay / 2),
478
- );
479
- case HttpRetryJitter.Full:
480
- return Math.floor(Math.random() * configuredDelay);
481
- }
67
+ function isDedupeSkipOutcome(outcome: NativeHttpAttemptOutcome): outcome is NativeHttpSkipOutcome {
68
+ return "kind" in outcome && outcome.kind === "dedupe-skip";
482
69
  }
483
70
 
484
71
  async function sleep(ms: number): Promise<void> {
@@ -505,9 +92,7 @@ function withClientHeaders(
505
92
  body: unknown,
506
93
  ): RequestOptions {
507
94
  const headers: Record<string, string> = {
508
- ...(clientOptions.userAgent
509
- ? { "User-Agent": clientOptions.userAgent }
510
- : {}),
95
+ ...(clientOptions.userAgent ? { "User-Agent": clientOptions.userAgent } : {}),
511
96
  ...options?.headers,
512
97
  };
513
98
 
@@ -522,10 +107,7 @@ function withClientHeaders(
522
107
  }
523
108
 
524
109
  function parseHttpData(body: string, headers: Record<string, string>): unknown {
525
- const contentType =
526
- headers["content-type"] ??
527
- headers["Content-Type"] ??
528
- headers["CONTENT-TYPE"];
110
+ const contentType = headers["content-type"] ?? headers["Content-Type"] ?? headers["CONTENT-TYPE"];
529
111
 
530
112
  if (contentType?.includes("application/json")) {
531
113
  return body ? JSON.parse(body) : null;
@@ -596,23 +178,14 @@ async function toNativeHttpResponse(response: Response): Promise<HttpResponse> {
596
178
  headers,
597
179
  json: async <T = unknown>() => {
598
180
  const contentType =
599
- headers["content-type"] ??
600
- headers["Content-Type"] ??
601
- headers["CONTENT-TYPE"];
602
- return parseJson<T>(
603
- contentType?.includes("application/json") && !rawText
604
- ? "null"
605
- : rawText,
606
- );
181
+ headers["content-type"] ?? headers["Content-Type"] ?? headers["CONTENT-TYPE"];
182
+ return parseJson<T>(contentType?.includes("application/json") && !rawText ? "null" : rawText);
607
183
  },
608
184
  ok: response.status >= 200 && response.status < 300,
609
185
  status: response.status,
610
186
  text: async () => rawText,
611
187
  arrayBuffer: async () =>
612
- bodyBytes.buffer.slice(
613
- bodyBytes.byteOffset,
614
- bodyBytes.byteOffset + bodyBytes.byteLength,
615
- ),
188
+ bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength),
616
189
  bytes: async () => bodyBytes.slice(0),
617
190
  };
618
191
  }
@@ -625,9 +198,7 @@ async function drainNativeResponseBody(response: Response): Promise<void> {
625
198
  }
626
199
  }
627
200
 
628
- function requireNativeResponseBody(
629
- response: Response,
630
- ): ReadableStream<Uint8Array> {
201
+ function requireNativeResponseBody(response: Response): ReadableStream<Uint8Array> {
631
202
  if (!response.body) {
632
203
  throw new TransportError("Response body stream is unavailable", {
633
204
  code: "transport_stream_unavailable",
@@ -692,18 +263,19 @@ async function resolveNativeProxy(
692
263
  warn: (message: string) => void,
693
264
  proxyAttemptOffset = 0,
694
265
  ): Promise<string | undefined> {
695
- const baseProxyAttempt =
696
- clientOptions.proxyAttempt === undefined ||
697
- !Number.isFinite(clientOptions.proxyAttempt)
698
- ? 0
699
- : Math.max(0, Math.floor(clientOptions.proxyAttempt));
700
266
  const resolvedProxy = await resolveProxyConfigAsync({
701
267
  proxy: options.proxy ?? clientOptions.proxy,
702
268
  upstream: clientOptions.upstream,
703
269
  apifuseConfig: clientOptions.apifuseConfig,
704
270
  proxyPolicy: clientOptions.proxyPolicy,
705
271
  affinityKey: clientOptions.affinityKey,
706
- proxyAttempt: baseProxyAttempt + proxyAttemptOffset,
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"],
707
279
  telemetry: clientOptions.telemetry,
708
280
  });
709
281
  if (resolvedProxy.shouldWarn) {
@@ -723,9 +295,7 @@ function assertNoHttpTransportOverrides(options: RequestOptions): void {
723
295
  }
724
296
  }
725
297
 
726
- function normalizeNativeFetchBody(
727
- body: unknown,
728
- ): string | ArrayBuffer | undefined {
298
+ function normalizeNativeFetchBody(body: unknown): string | ArrayBuffer | undefined {
729
299
  const normalized = normalizeHttpRequestBody(body);
730
300
  if (!Buffer.isBuffer(normalized)) {
731
301
  return normalized;
@@ -744,11 +314,9 @@ async function fetchNativeHttp(
744
314
  warn: (message: string) => void,
745
315
  statusRetryCodes?: readonly number[],
746
316
  proxyAttemptOffset = 0,
317
+ dedupe?: { attempted: Set<string> },
747
318
  ): Promise<NativeHttpAttemptOutcome> {
748
- const requestUrl = appendQueryParams(
749
- resolveHttpUrl(baseUrl, url),
750
- options.params,
751
- );
319
+ const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
752
320
  const controller = options.timeout ? new AbortController() : undefined;
753
321
  const timeoutHandle = options.timeout
754
322
  ? setTimeout(() => controller?.abort(), options.timeout)
@@ -756,12 +324,20 @@ async function fetchNativeHttp(
756
324
 
757
325
  let proxy: string | undefined;
758
326
  try {
759
- proxy = await resolveNativeProxy(
760
- options,
761
- clientOptions,
762
- warn,
763
- proxyAttemptOffset,
764
- );
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
+ }
765
341
  const requestInit: NativeFetchInit = {
766
342
  headers: options.headers,
767
343
  method,
@@ -789,13 +365,10 @@ async function fetchNativeHttp(
789
365
 
790
366
  if (response.status >= 400 && options.throwOnHttpError !== false) {
791
367
  await drainNativeResponseBody(response);
792
- throw new TransportError(
793
- `Upstream request failed with status ${response.status}`,
794
- {
795
- code: "upstream_http_error",
796
- status: response.status,
797
- },
798
- );
368
+ throw new TransportError(`Upstream request failed with status ${response.status}`, {
369
+ code: "upstream_http_error",
370
+ status: response.status,
371
+ });
799
372
  }
800
373
 
801
374
  return toNativeHttpResponse(response);
@@ -819,10 +392,7 @@ async function fetchNativeHttpStream(
819
392
  clientOptions: HttpClientOptions,
820
393
  warn: (message: string) => void,
821
394
  ): Promise<HttpStreamResponse> {
822
- const requestUrl = appendQueryParams(
823
- resolveHttpUrl(baseUrl, url),
824
- options.params,
825
- );
395
+ const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
826
396
  const controller = options.timeout ? new AbortController() : undefined;
827
397
  const timeoutHandle = options.timeout
828
398
  ? setTimeout(() => controller?.abort(), options.timeout)
@@ -845,13 +415,10 @@ async function fetchNativeHttpStream(
845
415
 
846
416
  if (response.status >= 400 && options.throwOnHttpError !== false) {
847
417
  await drainNativeResponseBody(response);
848
- throw new TransportError(
849
- `Upstream request failed with status ${response.status}`,
850
- {
851
- code: "upstream_http_error",
852
- status: response.status,
853
- },
854
- );
418
+ throw new TransportError(`Upstream request failed with status ${response.status}`, {
419
+ code: "upstream_http_error",
420
+ status: response.status,
421
+ });
855
422
  }
856
423
 
857
424
  return toNativeHttpStreamResponse(response);
@@ -891,23 +458,19 @@ export function createHttpClient(
891
458
  );
892
459
  }
893
460
  assertNoHttpTransportOverrides(options);
894
- const headersOptions = withClientHeaders(
895
- options,
896
- clientOptions,
897
- options.body,
898
- );
461
+ const headersOptions = withClientHeaders(options, clientOptions, options.body);
899
462
  const methodName = normalizeHttpMethod(method);
900
463
  const explicitRetry = headersOptions.retry !== undefined;
901
464
  const retryOptions =
902
- normalizeRetryOptions(headersOptions.retry) ??
903
- (explicitRetry
904
- ? undefined
905
- : createRetryOptions(HttpRetryPreset.TransportTransient));
906
- if (retryOptions) validateUnsafeRetryMethods(retryOptions);
465
+ normalizeProxyTransportRetryOptions(headersOptions.retry, {
466
+ label: "HTTP",
467
+ }) ??
468
+ (explicitRetry ? undefined : createDefaultProxyTransportRetryOptions({ label: "HTTP" }));
469
+ if (retryOptions) validateUnsafeProxyTransportRetryMethods(retryOptions, "HTTP");
907
470
  const retryEnabled = Boolean(
908
471
  retryOptions &&
909
472
  retryOptions.attempts > 1 &&
910
- isMethodRetryable(methodName, retryOptions),
473
+ isProxyTransportRetryMethod(methodName, retryOptions),
911
474
  );
912
475
  const statusRetryEnabled = Boolean(
913
476
  retryEnabled &&
@@ -916,14 +479,63 @@ export function createHttpClient(
916
479
  retryOptions.statusCodes.length > 0 &&
917
480
  headersOptions.throwOnHttpError !== false,
918
481
  );
919
- const attemptOptions: RequestOptions & { body?: unknown } =
920
- statusRetryEnabled
921
- ? { ...headersOptions, throwOnHttpError: false }
922
- : 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;
512
+
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;
923
537
 
924
- const executeOnce = (
925
- proxyAttemptOffset = 0,
926
- ): Promise<NativeHttpAttemptOutcome> =>
538
+ const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
927
539
  fetchNativeHttp(
928
540
  baseUrl,
929
541
  url,
@@ -933,44 +545,67 @@ export function createHttpClient(
933
545
  warnOnce,
934
546
  statusRetryEnabled ? retryOptions?.statusCodes : undefined,
935
547
  proxyAttemptOffset,
548
+ dedupeContext,
936
549
  );
937
550
 
938
551
  if (!retryEnabled || !retryOptions) {
939
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
+ }
940
560
  if (isHttpStatusOutcome(outcome)) {
941
561
  throw toUpstreamHttpError(outcome.status);
942
562
  }
943
563
  return outcome;
944
564
  }
945
565
 
566
+ let lastError: unknown;
946
567
  let lastErrorCode: string | undefined;
947
568
  let lastStatus: number | undefined;
948
- 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;
949
581
  try {
950
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;
951
591
  if (isHttpStatusOutcome(outcome)) {
952
592
  lastStatus = outcome.status;
953
- if (outcome.retryable && attempt < retryOptions.attempts) {
954
- await sleep(
955
- computeRetryDelayMs(retryOptions, attempt, outcome.headers),
956
- );
593
+ if (outcome.retryable && issued < retryOptions.attempts) {
594
+ await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
957
595
  continue;
958
596
  }
959
597
  throw toUpstreamHttpError(outcome.status);
960
598
  }
961
599
 
962
600
  const response = outcome;
963
- if (
964
- response.status >= 400 &&
965
- headersOptions.throwOnHttpError !== false
966
- ) {
601
+ if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
967
602
  throw toUpstreamHttpError(response.status);
968
603
  }
969
604
 
970
- if (attempt > 1) {
605
+ if (issued > 1) {
971
606
  const summary: HttpRetrySummary = {
972
- attempts: attempt,
973
- retries: attempt - 1,
607
+ attempts: issued,
608
+ retries: issued - 1,
974
609
  ...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
975
610
  transport: "native",
976
611
  ...(lastErrorCode ? { lastErrorCode } : {}),
@@ -980,21 +615,35 @@ export function createHttpClient(
980
615
  }
981
616
  return response;
982
617
  } catch (error) {
983
- lastErrorCode = retryErrorCode(error);
984
- lastStatus = retryErrorStatus(error);
618
+ if (!issuedThisAttempt) issued += 1;
619
+ lastError = error;
620
+ lastErrorCode = proxyTransportRetryErrorCode(error);
621
+ lastStatus = proxyTransportRetryErrorStatus(error);
985
622
  const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
986
623
  if (
987
- attempt < retryOptions.attempts &&
988
- (explicitRetry || proxyUsed) &&
989
- shouldRetryTransportError(error, retryOptions)
624
+ attempt < transportAttemptCap &&
625
+ shouldRetryProxyTransportAttempt({
626
+ error,
627
+ explicitRetry,
628
+ method: methodName,
629
+ options: retryOptions,
630
+ proxyUsed,
631
+ })
990
632
  ) {
991
- await sleep(computeRetryDelayMs(retryOptions, attempt));
633
+ await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt));
992
634
  continue;
993
635
  }
994
636
  throw error;
995
637
  }
996
638
  }
997
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
+ }
998
647
  throw new TransportError("HTTP retry exhausted without a terminal result", {
999
648
  code: "retry_exhausted",
1000
649
  });
@@ -1012,30 +661,17 @@ export function createHttpClient(
1012
661
  );
1013
662
  }
1014
663
  assertNoHttpTransportOverrides(options);
1015
- const headersOptions = withClientHeaders(
1016
- options,
1017
- clientOptions,
1018
- options.body,
1019
- );
664
+ const headersOptions = withClientHeaders(options, clientOptions, options.body);
1020
665
  const methodName = normalizeHttpMethod(method);
1021
- return fetchNativeHttpStream(
1022
- baseUrl,
1023
- url,
1024
- methodName,
1025
- headersOptions,
1026
- clientOptions,
1027
- warnOnce,
1028
- );
666
+ return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
1029
667
  }
1030
668
 
1031
669
  return {
1032
670
  request: async (url, options: RequestWithMethodOptions = {}) =>
1033
671
  request(url, options.method ?? "GET", options),
1034
672
  get: async (url, options) => request(url, "GET", options),
1035
- post: async (url, body, options) =>
1036
- request(url, "POST", { ...options, body }),
1037
- put: async (url, body, options) =>
1038
- 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 }),
1039
675
  delete: async (url, options) => request(url, "DELETE", options),
1040
676
  stream: async (url, options: RequestWithMethodOptions = {}) =>
1041
677
  streamRequest(url, options.method ?? "GET", options),