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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/AUTHORING.md +330 -8
  2. package/CHANGELOG.md +89 -1
  3. package/README.md +64 -17
  4. package/SUBMISSION.md +86 -0
  5. package/bin/apifuse-check.ts +60 -6
  6. package/bin/apifuse-dev.ts +58 -8
  7. package/bin/apifuse-pack-check.ts +32 -2
  8. package/bin/apifuse-pack-smoke.ts +133 -6
  9. package/bin/apifuse-perf.ts +142 -49
  10. package/bin/apifuse-record.ts +182 -104
  11. package/bin/apifuse-submit-check.ts +3243 -0
  12. package/bin/apifuse.ts +1 -1
  13. package/dist/auth.d.ts +76 -0
  14. package/dist/auth.js +436 -0
  15. package/dist/ceremonies/index.d.ts +41 -0
  16. package/dist/ceremonies/index.js +490 -0
  17. package/dist/choice-token.d.ts +24 -0
  18. package/dist/choice-token.js +74 -0
  19. package/dist/cli/commands.d.ts +10 -0
  20. package/dist/cli/commands.js +80 -0
  21. package/dist/cli/create.d.ts +47 -0
  22. package/dist/cli/create.js +777 -0
  23. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  24. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  25. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  26. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  27. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  28. package/dist/cli/templates/provider/README.md.tpl +163 -0
  29. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  30. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  31. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  32. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  33. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  34. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  35. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  36. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  37. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  38. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  39. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  40. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  41. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  42. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  43. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  44. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  45. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  46. package/dist/config/loader.d.ts +107 -0
  47. package/dist/config/loader.js +935 -0
  48. package/dist/contract-json.d.ts +9 -0
  49. package/dist/contract-json.js +51 -0
  50. package/dist/contract-serialization.d.ts +4 -0
  51. package/dist/contract-serialization.js +78 -0
  52. package/dist/contract-types.d.ts +49 -0
  53. package/dist/contract-types.js +1 -0
  54. package/dist/contract.d.ts +6 -0
  55. package/dist/contract.js +156 -0
  56. package/dist/define.d.ts +100 -0
  57. package/dist/define.js +1383 -0
  58. package/dist/dev.d.ts +9 -0
  59. package/dist/dev.js +15 -0
  60. package/dist/errors.d.ts +59 -0
  61. package/dist/errors.js +97 -0
  62. package/dist/i18n/catalog.d.ts +29 -0
  63. package/dist/i18n/catalog.js +159 -0
  64. package/dist/i18n/index.d.ts +2 -0
  65. package/dist/i18n/index.js +2 -0
  66. package/dist/i18n/keys.d.ts +10 -0
  67. package/dist/i18n/keys.js +34 -0
  68. package/dist/index.d.ts +42 -0
  69. package/dist/index.js +38 -0
  70. package/dist/lint.d.ts +74 -0
  71. package/dist/lint.js +729 -0
  72. package/dist/observability.d.ts +5 -0
  73. package/dist/observability.js +39 -0
  74. package/dist/provider.d.ts +11 -0
  75. package/dist/provider.js +9 -0
  76. package/dist/public-schema-field-lint.d.ts +2 -0
  77. package/dist/public-schema-field-lint.js +158 -0
  78. package/dist/recipes/gov-api.d.ts +19 -0
  79. package/dist/recipes/gov-api.js +72 -0
  80. package/dist/recipes/rest-api.d.ts +21 -0
  81. package/dist/recipes/rest-api.js +115 -0
  82. package/dist/runtime/auth-flow.d.ts +14 -0
  83. package/dist/runtime/auth-flow.js +46 -0
  84. package/dist/runtime/browser.d.ts +25 -0
  85. package/dist/runtime/browser.js +1237 -0
  86. package/dist/runtime/cache.d.ts +10 -0
  87. package/dist/runtime/cache.js +372 -0
  88. package/dist/runtime/choice.d.ts +15 -0
  89. package/dist/runtime/choice.js +435 -0
  90. package/dist/runtime/credential.d.ts +8 -0
  91. package/dist/runtime/credential.js +61 -0
  92. package/dist/runtime/env.d.ts +2 -0
  93. package/dist/runtime/env.js +10 -0
  94. package/dist/runtime/executor.d.ts +16 -0
  95. package/dist/runtime/executor.js +51 -0
  96. package/dist/runtime/http.d.ts +8 -0
  97. package/dist/runtime/http.js +726 -0
  98. package/dist/runtime/insights.d.ts +9 -0
  99. package/dist/runtime/insights.js +324 -0
  100. package/dist/runtime/instrumentation.d.ts +8 -0
  101. package/dist/runtime/instrumentation.js +269 -0
  102. package/dist/runtime/key-derivation.d.ts +24 -0
  103. package/dist/runtime/key-derivation.js +73 -0
  104. package/dist/runtime/keyring.d.ts +25 -0
  105. package/dist/runtime/keyring.js +93 -0
  106. package/dist/runtime/namespace.d.ts +9 -0
  107. package/dist/runtime/namespace.js +19 -0
  108. package/dist/runtime/otlp.d.ts +39 -0
  109. package/dist/runtime/otlp.js +103 -0
  110. package/dist/runtime/perf.d.ts +12 -0
  111. package/dist/runtime/perf.js +52 -0
  112. package/dist/runtime/prevalidate.d.ts +12 -0
  113. package/dist/runtime/prevalidate.js +173 -0
  114. package/dist/runtime/provider.d.ts +2 -0
  115. package/dist/runtime/provider.js +11 -0
  116. package/dist/runtime/proxy-errors.d.ts +21 -0
  117. package/dist/runtime/proxy-errors.js +83 -0
  118. package/dist/runtime/proxy-telemetry.d.ts +8 -0
  119. package/dist/runtime/proxy-telemetry.js +174 -0
  120. package/dist/runtime/redis.d.ts +17 -0
  121. package/dist/runtime/redis.js +82 -0
  122. package/dist/runtime/request-options.d.ts +3 -0
  123. package/dist/runtime/request-options.js +42 -0
  124. package/dist/runtime/state.d.ts +17 -0
  125. package/dist/runtime/state.js +344 -0
  126. package/dist/runtime/stealth.d.ts +21 -0
  127. package/dist/runtime/stealth.js +980 -0
  128. package/dist/runtime/stt.d.ts +22 -0
  129. package/dist/runtime/stt.js +480 -0
  130. package/dist/runtime/trace.d.ts +26 -0
  131. package/dist/runtime/trace.js +142 -0
  132. package/dist/runtime/waterfall.d.ts +12 -0
  133. package/dist/runtime/waterfall.js +147 -0
  134. package/dist/schema.d.ts +74 -0
  135. package/dist/schema.js +243 -0
  136. package/dist/serve.d.ts +1 -0
  137. package/dist/serve.js +1 -0
  138. package/dist/server/index.d.ts +3 -0
  139. package/dist/server/index.js +2 -0
  140. package/dist/server/serve.d.ts +64 -0
  141. package/dist/server/serve.js +1118 -0
  142. package/dist/server/types.d.ts +136 -0
  143. package/dist/server/types.js +86 -0
  144. package/dist/stealth/profiles.d.ts +4 -0
  145. package/dist/stealth/profiles.js +259 -0
  146. package/dist/stream.d.ts +44 -0
  147. package/dist/stream.js +151 -0
  148. package/dist/testing/helpers.d.ts +23 -0
  149. package/dist/testing/helpers.js +95 -0
  150. package/dist/testing/index.d.ts +2 -0
  151. package/dist/testing/index.js +2 -0
  152. package/dist/testing/run.d.ts +34 -0
  153. package/dist/testing/run.js +307 -0
  154. package/dist/types.d.ts +1467 -0
  155. package/dist/types.js +61 -0
  156. package/dist/utils/date.d.ts +6 -0
  157. package/dist/utils/date.js +101 -0
  158. package/dist/utils/parse.d.ts +16 -0
  159. package/dist/utils/parse.js +51 -0
  160. package/dist/utils/text.d.ts +4 -0
  161. package/dist/utils/text.js +14 -0
  162. package/dist/utils/transform.d.ts +8 -0
  163. package/dist/utils/transform.js +48 -0
  164. package/package.json +57 -29
  165. package/src/auth.ts +786 -0
  166. package/src/ceremonies/index.ts +8 -2
  167. package/src/choice-token.ts +165 -0
  168. package/src/cli/commands.ts +34 -11
  169. package/src/cli/create.ts +254 -128
  170. package/src/cli/templates/provider/.dockerignore.tpl +22 -0
  171. package/src/cli/templates/provider/.gitignore.tpl +22 -0
  172. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  173. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  174. package/src/cli/templates/provider/README.md.tpl +87 -7
  175. package/src/cli/templates/provider/dev.ts.tpl +1 -1
  176. package/src/cli/templates/provider/domain/README.md.tpl +3 -0
  177. package/src/cli/templates/provider/index.ts.tpl +5 -47
  178. package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
  179. package/src/cli/templates/provider/meta.ts.tpl +7 -0
  180. package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
  181. package/src/cli/templates/provider/operations/ping.ts.tpl +24 -0
  182. package/src/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  183. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  184. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  185. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  186. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  187. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  188. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  189. package/src/cli/templates/provider/start.ts.tpl +1 -1
  190. package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
  191. package/src/config/loader.ts +1224 -9
  192. package/src/contract-json.ts +75 -0
  193. package/src/contract-serialization.ts +89 -0
  194. package/src/contract-types.ts +52 -0
  195. package/src/contract.ts +216 -0
  196. package/src/define.ts +1820 -70
  197. package/src/errors.ts +27 -0
  198. package/src/i18n/catalog.ts +277 -0
  199. package/src/i18n/index.ts +2 -0
  200. package/src/i18n/keys.ts +64 -0
  201. package/src/index.ts +189 -9
  202. package/src/lint.ts +580 -73
  203. package/src/observability.ts +41 -0
  204. package/src/provider.ts +131 -4
  205. package/src/public-schema-field-lint.ts +237 -0
  206. package/src/runtime/auth-flow.ts +9 -0
  207. package/src/runtime/browser.ts +1054 -51
  208. package/src/runtime/cache.ts +528 -0
  209. package/src/runtime/choice.ts +760 -0
  210. package/src/runtime/executor.ts +32 -3
  211. package/src/runtime/http.ts +980 -195
  212. package/src/runtime/insights.ts +11 -11
  213. package/src/runtime/instrumentation.ts +12 -4
  214. package/src/runtime/key-derivation.ts +1 -1
  215. package/src/runtime/keyring.ts +4 -3
  216. package/src/runtime/proxy-errors.ts +132 -0
  217. package/src/runtime/proxy-telemetry.ts +253 -0
  218. package/src/runtime/redis.ts +116 -0
  219. package/src/runtime/request-options.ts +66 -0
  220. package/src/runtime/state.ts +563 -0
  221. package/src/runtime/stealth.ts +1336 -0
  222. package/src/runtime/stt.ts +629 -0
  223. package/src/runtime/trace.ts +1 -1
  224. package/src/schema.ts +363 -1
  225. package/src/server/serve.ts +1192 -75
  226. package/src/server/types.ts +37 -0
  227. package/src/stream.ts +210 -0
  228. package/src/testing/run.ts +40 -6
  229. package/src/types.ts +1283 -59
  230. package/src/runtime/tls.ts +0 -434
  231. package/src/types/playwright-stealth.d.ts +0 -9
@@ -1,272 +1,1057 @@
1
- import { createRequire } from "node:module";
2
-
3
1
  import type { ProxyResolutionOptions } from "../config/loader";
4
- import { resolveProxyConfig } from "../config/loader";
5
- import { TransportError } from "../errors";
6
- import { generateLayer2Headers } from "../stealth/profiles";
2
+ import { resolveProxyConfigAsync } from "../config/loader";
3
+ import { ProviderError, TransportError } from "../errors";
4
+ import {
5
+ parseSseStream,
6
+ readableBytes,
7
+ readableLines,
8
+ readableTextChunks,
9
+ } from "../stream";
7
10
  import type {
8
11
  HttpClient,
12
+ HttpMethod,
9
13
  HttpResponse,
14
+ HttpRetryOptions,
15
+ HttpRetrySummary,
16
+ HttpStreamResponse,
10
17
  RequestOptions,
11
18
  RequestWithMethodOptions,
12
- StealthProfile,
19
+ SseMessage,
13
20
  } from "../types";
21
+ import {
22
+ HttpRetryAfterPolicy,
23
+ HttpRetryDelayStrategy,
24
+ HttpRetryJitter,
25
+ HttpRetryPreset,
26
+ HttpRetryUnsafeMethodPolicy,
27
+ } from "../types";
28
+ import { appendQueryParams, normalizeHttpRequestBody } from "./request-options";
14
29
 
15
- const require = createRequire(import.meta.url);
16
-
17
- const MISSING_PROXY_WARNING =
18
- "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
19
-
20
- const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
30
+ const DEFAULT_HTTP_BASE_URL = "http://localhost";
21
31
 
22
- type FetchProxyInit = RequestInit & {
23
- dispatcher?: unknown;
24
- proxy?: string;
32
+ export type HttpClientOptions = ProxyResolutionOptions & {
33
+ warn?: (message: string) => void;
34
+ userAgent?: string;
35
+ onRetrySummary?: (summary: HttpRetrySummary) => void;
25
36
  };
26
37
 
27
- type UndiciModule = {
28
- ProxyAgent?: new (proxyUrl: string) => unknown;
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[];
29
54
  };
30
55
 
31
- export type HttpClientOptions = ProxyResolutionOptions & {
32
- warn?: (message: string) => void;
33
- stealthProfile?: StealthProfile;
34
- userAgent?: string;
56
+ type HttpStatusOutcome = {
57
+ kind: "http-status";
58
+ status: number;
59
+ headers: Record<string, string>;
60
+ retryable: boolean;
61
+ proxyUsed: boolean;
35
62
  };
36
63
 
37
- function buildUrl(url: string, params?: Record<string, string>): string {
38
- if (!params || Object.keys(params).length === 0) {
39
- return url;
40
- }
64
+ type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome;
65
+
66
+ type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
41
67
 
42
- const searchParams = new URLSearchParams(params);
43
- return `${url}?${searchParams.toString()}`;
68
+ function isHttpStatusOutcome(
69
+ outcome: HttpResponse | HttpStatusOutcome,
70
+ ): outcome is HttpStatusOutcome {
71
+ return "kind" in outcome && outcome.kind === "http-status";
44
72
  }
45
73
 
46
- function resolveUrl(baseUrl: string | undefined, url: string): string {
47
- if (!baseUrl) {
48
- return url;
49
- }
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;
50
100
 
51
- return new URL(url, baseUrl).toString();
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);
52
107
  }
53
108
 
54
- async function doRequest(
55
- baseUrl: string | undefined,
56
- url: string,
57
- method: string,
58
- proxy: string | undefined,
59
- options: RequestOptions & { body?: unknown } = {},
60
- ): Promise<HttpResponse> {
61
- const { headers, params, body } = options;
62
- const timeout = options.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
109
+ function createInvalidRetryPolicyError(message: string): ProviderError {
110
+ return new ProviderError(message, { code: "retry_invalid_policy" });
111
+ }
63
112
 
64
- const controller = new AbortController();
65
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
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
+ }
66
204
 
67
- if (timeout) {
68
- timeoutId = setTimeout(() => controller.abort(), timeout);
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
+ );
69
229
  }
70
230
 
71
- try {
72
- const requestUrl = buildUrl(resolveUrl(baseUrl, url), params);
73
- const proxyInit = createProxyInit(proxy);
74
- const response = await fetch(requestUrl, {
75
- ...proxyInit,
76
- method,
77
- headers: {
78
- "Content-Type": "application/json",
79
- ...headers,
80
- },
81
- body: body !== undefined ? JSON.stringify(body) : undefined,
82
- signal: controller.signal,
83
- });
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
+ };
84
256
 
85
- if (!response.ok) {
86
- await drainFetchResponse(response);
87
- throw new TransportError(
88
- `Upstream request failed with status ${response.status}`,
89
- {
90
- code: "upstream_http_error",
91
- status: response.status,
92
- },
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",
93
328
  );
94
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
+ }
95
385
 
96
- const contentType = response.headers.get("content-type") ?? "";
97
- const rawText = await response.text();
98
- let data: unknown;
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
+ }
99
444
 
100
- if (contentType.includes("application/json")) {
101
- data = rawText ? (JSON.parse(rawText) as unknown) : null;
102
- } else {
103
- data = rawText;
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);
104
468
  }
469
+ return boundedRetryAfterMs;
470
+ }
105
471
 
106
- return {
107
- data,
108
- headers: Object.fromEntries(response.headers.entries()),
109
- json: async <T = unknown>() =>
110
- contentType.includes("application/json")
111
- ? (data as T)
112
- : (JSON.parse(rawText) as T),
113
- ok: response.ok,
114
- status: response.status,
115
- text: async () => rawText,
116
- };
117
- } catch (error) {
118
- if (error instanceof TransportError) {
119
- throw error;
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
+ }
482
+ }
483
+
484
+ async function sleep(ms: number): Promise<void> {
485
+ if (ms <= 0) return;
486
+ await new Promise((resolve) => setTimeout(resolve, ms));
487
+ }
488
+
489
+ function toUpstreamHttpError(status: number): TransportError {
490
+ return new TransportError(`Upstream request failed with status ${status}`, {
491
+ code: "upstream_http_error",
492
+ status,
493
+ upstreamStatus: status,
494
+ });
495
+ }
496
+
497
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
498
+ const needle = name.toLowerCase();
499
+ return Object.keys(headers).some((key) => key.toLowerCase() === needle);
500
+ }
501
+
502
+ function withClientHeaders(
503
+ options: RequestOptions | undefined,
504
+ clientOptions: HttpClientOptions,
505
+ body: unknown,
506
+ ): RequestOptions {
507
+ const headers: Record<string, string> = {
508
+ ...(clientOptions.userAgent
509
+ ? { "User-Agent": clientOptions.userAgent }
510
+ : {}),
511
+ ...options?.headers,
512
+ };
513
+
514
+ if (body !== undefined && !hasHeader(headers, "Content-Type")) {
515
+ headers["Content-Type"] = "application/json";
516
+ }
517
+
518
+ return {
519
+ ...options,
520
+ headers,
521
+ };
522
+ }
523
+
524
+ function parseHttpData(body: string, headers: Record<string, string>): unknown {
525
+ const contentType =
526
+ headers["content-type"] ??
527
+ headers["Content-Type"] ??
528
+ headers["CONTENT-TYPE"];
529
+
530
+ if (contentType?.includes("application/json")) {
531
+ return body ? JSON.parse(body) : null;
532
+ }
533
+
534
+ return body;
535
+ }
536
+
537
+ function parseJson<T = unknown>(body: string): T {
538
+ return JSON.parse(body);
539
+ }
540
+
541
+ function isTimeoutMessage(message: string): boolean {
542
+ return /\b(timed out|timeout|deadline exceeded)\b/i.test(message);
543
+ }
544
+
545
+ function toHttpTransportError(error: unknown): TransportError {
546
+ if (error instanceof TransportError) {
547
+ if (error.code) {
548
+ return error;
120
549
  }
121
550
 
122
- if (error instanceof Error && error.name === "AbortError") {
123
- throw new TransportError("Request timed out", {
551
+ if (isTimeoutMessage(error.message)) {
552
+ return new TransportError("Request timed out", {
124
553
  code: "transport_timeout",
554
+ status: error.status ?? 0,
555
+ cause: error,
125
556
  });
126
557
  }
127
558
 
128
- throw new TransportError("Network error", {
129
- code: "transport_network_error",
130
- cause: error instanceof Error ? error : undefined,
131
- });
132
- } finally {
133
- if (timeoutId) {
134
- clearTimeout(timeoutId);
559
+ if ((error.status ?? 0) === 0) {
560
+ return new TransportError(error.message || "Network error", {
561
+ code: "transport_network_error",
562
+ status: 0,
563
+ cause: error,
564
+ });
135
565
  }
566
+
567
+ return error;
136
568
  }
137
- }
138
569
 
139
- async function drainFetchResponse(response: Response): Promise<void> {
140
- const reader = response.body?.getReader();
141
- if (!reader) {
142
- return;
570
+ if (error instanceof Error) {
571
+ const timeout =
572
+ error.name === "AbortError" ||
573
+ error.name === "TimeoutError" ||
574
+ isTimeoutMessage(error.message);
575
+ return new TransportError(timeout ? "Request timed out" : "Network error", {
576
+ code: timeout ? "transport_timeout" : "transport_network_error",
577
+ status: 0,
578
+ cause: error,
579
+ });
143
580
  }
144
581
 
582
+ return new TransportError("Network error", {
583
+ code: "transport_network_error",
584
+ status: 0,
585
+ });
586
+ }
587
+
588
+ async function toNativeHttpResponse(response: Response): Promise<HttpResponse> {
589
+ const headers = Object.fromEntries(response.headers.entries());
590
+ const bodyBytes = new Uint8Array(await response.arrayBuffer());
591
+ const rawText = new TextDecoder().decode(bodyBytes);
592
+ const data = parseHttpData(rawText, headers);
593
+
594
+ return {
595
+ data,
596
+ headers,
597
+ json: async <T = unknown>() => {
598
+ 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
+ );
607
+ },
608
+ ok: response.status >= 200 && response.status < 300,
609
+ status: response.status,
610
+ text: async () => rawText,
611
+ arrayBuffer: async () =>
612
+ bodyBytes.buffer.slice(
613
+ bodyBytes.byteOffset,
614
+ bodyBytes.byteOffset + bodyBytes.byteLength,
615
+ ),
616
+ bytes: async () => bodyBytes.slice(0),
617
+ };
618
+ }
619
+
620
+ async function drainNativeResponseBody(response: Response): Promise<void> {
145
621
  try {
146
- while (true) {
147
- const { done } = await reader.read();
148
- if (done) {
149
- return;
150
- }
151
- }
622
+ await response.arrayBuffer();
152
623
  } catch {
153
- // Best-effort drain for transport reuse only; callers still receive the
154
- // sanitized upstream error below.
155
- } finally {
156
- reader.releaseLock();
624
+ await response.body?.cancel().catch(() => undefined);
157
625
  }
158
626
  }
159
627
 
160
- function createProxyInit(
161
- proxy?: string,
162
- ): Pick<FetchProxyInit, "dispatcher" | "proxy"> {
163
- if (!proxy) {
164
- return {};
628
+ function requireNativeResponseBody(
629
+ response: Response,
630
+ ): ReadableStream<Uint8Array> {
631
+ if (!response.body) {
632
+ throw new TransportError("Response body stream is unavailable", {
633
+ code: "transport_stream_unavailable",
634
+ status: response.status,
635
+ });
165
636
  }
637
+ return response.body;
638
+ }
166
639
 
167
- const bunRuntime = Object.getOwnPropertyDescriptor(globalThis, "Bun")?.value;
640
+ function toNativeHttpStreamResponse(response: Response): HttpStreamResponse {
641
+ const headers = Object.fromEntries(response.headers.entries());
642
+ const body = requireNativeResponseBody(response);
643
+ return {
644
+ body,
645
+ headers,
646
+ ok: response.status >= 200 && response.status < 300,
647
+ status: response.status,
648
+ bytes: () => readableBytes(body),
649
+ textChunks: () => readableTextChunks(body),
650
+ lines: () => readableLines(body),
651
+ };
652
+ }
168
653
 
169
- if (bunRuntime !== undefined) {
170
- return { proxy };
654
+ function normalizeHttpMethod(method: string): HttpMethod {
655
+ switch (method.toUpperCase()) {
656
+ case "HEAD":
657
+ return "HEAD";
658
+ case "GET":
659
+ return "GET";
660
+ case "POST":
661
+ return "POST";
662
+ case "PUT":
663
+ return "PUT";
664
+ case "DELETE":
665
+ return "DELETE";
666
+ case "OPTIONS":
667
+ return "OPTIONS";
668
+ case "TRACE":
669
+ return "TRACE";
670
+ case "PATCH":
671
+ return "PATCH";
672
+ default:
673
+ throw new TransportError(`Unsupported HTTP method: ${method}`, {
674
+ code: "transport_invalid_method",
675
+ });
171
676
  }
677
+ }
678
+
679
+ function isAbsoluteUrl(url: string): boolean {
680
+ return /^[a-z][a-z\d+\-.]*:/i.test(url);
681
+ }
172
682
 
683
+ function resolveHttpUrl(baseUrl: string | undefined, url: string): string {
684
+ return new URL(url, baseUrl ?? DEFAULT_HTTP_BASE_URL).toString();
685
+ }
686
+
687
+ type NativeFetchInit = RequestInit & { proxy?: string };
688
+
689
+ async function resolveNativeProxy(
690
+ options: RequestOptions,
691
+ clientOptions: HttpClientOptions,
692
+ warn: (message: string) => void,
693
+ proxyAttemptOffset = 0,
694
+ ): 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
+ const resolvedProxy = await resolveProxyConfigAsync({
701
+ proxy: options.proxy ?? clientOptions.proxy,
702
+ upstream: clientOptions.upstream,
703
+ apifuseConfig: clientOptions.apifuseConfig,
704
+ proxyPolicy: clientOptions.proxyPolicy,
705
+ affinityKey: clientOptions.affinityKey,
706
+ proxyAttempt: baseProxyAttempt + proxyAttemptOffset,
707
+ telemetry: clientOptions.telemetry,
708
+ });
709
+ if (resolvedProxy.shouldWarn) {
710
+ warn(
711
+ "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.",
712
+ );
713
+ }
714
+ return resolvedProxy.url;
715
+ }
716
+
717
+ function assertNoHttpTransportOverrides(options: RequestOptions): void {
718
+ if ("profile" in options || "stealth" in options) {
719
+ throw new ProviderError(
720
+ "ctx.http does not accept stealth transport options. Use ctx.stealth.fetch() for browser-like impersonation.",
721
+ { code: "http_transport_override_unsupported" },
722
+ );
723
+ }
724
+ }
725
+
726
+ function normalizeNativeFetchBody(
727
+ body: unknown,
728
+ ): string | ArrayBuffer | undefined {
729
+ const normalized = normalizeHttpRequestBody(body);
730
+ if (!Buffer.isBuffer(normalized)) {
731
+ return normalized;
732
+ }
733
+ const copied = new Uint8Array(normalized.byteLength);
734
+ copied.set(normalized);
735
+ return copied.buffer;
736
+ }
737
+
738
+ async function fetchNativeHttp(
739
+ baseUrl: string | undefined,
740
+ url: string,
741
+ method: HttpMethod,
742
+ options: RequestOptions & { body?: unknown },
743
+ clientOptions: HttpClientOptions,
744
+ warn: (message: string) => void,
745
+ statusRetryCodes?: readonly number[],
746
+ proxyAttemptOffset = 0,
747
+ ): Promise<NativeHttpAttemptOutcome> {
748
+ const requestUrl = appendQueryParams(
749
+ resolveHttpUrl(baseUrl, url),
750
+ options.params,
751
+ );
752
+ const controller = options.timeout ? new AbortController() : undefined;
753
+ const timeoutHandle = options.timeout
754
+ ? setTimeout(() => controller?.abort(), options.timeout)
755
+ : undefined;
756
+
757
+ let proxy: string | undefined;
173
758
  try {
174
- const undici = require("undici") as UndiciModule;
175
- if (typeof undici.ProxyAgent === "function") {
759
+ proxy = await resolveNativeProxy(
760
+ options,
761
+ clientOptions,
762
+ warn,
763
+ proxyAttemptOffset,
764
+ );
765
+ const requestInit: NativeFetchInit = {
766
+ headers: options.headers,
767
+ method,
768
+ ...(proxy ? { proxy } : {}),
769
+ signal: controller?.signal,
770
+ };
771
+ if (options.body !== undefined) {
772
+ requestInit.body = normalizeNativeFetchBody(options.body);
773
+ }
774
+ const response = await fetch(requestUrl, {
775
+ ...requestInit,
776
+ });
777
+ const headers = Object.fromEntries(response.headers.entries());
778
+
779
+ if (statusRetryCodes && response.status >= 400) {
780
+ await drainNativeResponseBody(response);
176
781
  return {
177
- dispatcher: new undici.ProxyAgent(
178
- proxy,
179
- ) as FetchProxyInit["dispatcher"],
782
+ kind: "http-status",
783
+ status: response.status,
784
+ headers,
785
+ retryable: statusRetryCodes.includes(response.status),
786
+ proxyUsed: Boolean(proxy),
180
787
  };
181
788
  }
182
- } catch {
183
- // Fall through to a generic fetch init extension when undici is unavailable.
789
+
790
+ if (response.status >= 400 && options.throwOnHttpError !== false) {
791
+ 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
+ );
799
+ }
800
+
801
+ return toNativeHttpResponse(response);
802
+ } catch (error) {
803
+ if (error instanceof SyntaxError) {
804
+ throw error;
805
+ }
806
+ const transportError = toHttpTransportError(error) as NativeHttpAttemptError;
807
+ transportError.proxyUsed = Boolean(proxy);
808
+ throw transportError;
809
+ } finally {
810
+ if (timeoutHandle) clearTimeout(timeoutHandle);
184
811
  }
812
+ }
813
+
814
+ async function fetchNativeHttpStream(
815
+ baseUrl: string | undefined,
816
+ url: string,
817
+ method: HttpMethod,
818
+ options: RequestOptions & { body?: unknown },
819
+ clientOptions: HttpClientOptions,
820
+ warn: (message: string) => void,
821
+ ): Promise<HttpStreamResponse> {
822
+ const requestUrl = appendQueryParams(
823
+ resolveHttpUrl(baseUrl, url),
824
+ options.params,
825
+ );
826
+ const controller = options.timeout ? new AbortController() : undefined;
827
+ const timeoutHandle = options.timeout
828
+ ? setTimeout(() => controller?.abort(), options.timeout)
829
+ : undefined;
185
830
 
186
- return { proxy };
831
+ try {
832
+ const proxy = await resolveNativeProxy(options, clientOptions, warn);
833
+ const requestInit: NativeFetchInit = {
834
+ headers: options.headers,
835
+ method,
836
+ ...(proxy ? { proxy } : {}),
837
+ signal: controller?.signal,
838
+ };
839
+ if (options.body !== undefined) {
840
+ requestInit.body = normalizeNativeFetchBody(options.body);
841
+ }
842
+ const response = await fetch(requestUrl, {
843
+ ...requestInit,
844
+ });
845
+
846
+ if (response.status >= 400 && options.throwOnHttpError !== false) {
847
+ 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
+ );
855
+ }
856
+
857
+ return toNativeHttpStreamResponse(response);
858
+ } catch (error) {
859
+ if (error instanceof SyntaxError) {
860
+ throw error;
861
+ }
862
+ throw toHttpTransportError(error);
863
+ } finally {
864
+ if (timeoutHandle) clearTimeout(timeoutHandle);
865
+ }
187
866
  }
188
867
 
189
868
  export function createHttpClient(
190
869
  baseUrl?: string,
191
870
  clientOptions: HttpClientOptions = {},
192
871
  ): HttpClient {
193
- let hasWarnedMissingProxy = false;
872
+ const warnedMessages = new Set<string>();
194
873
  const warn = clientOptions.warn ?? console.warn;
195
- const stealthProfile = clientOptions.stealthProfile;
196
- const userAgent = clientOptions.userAgent ?? stealthProfile?.userAgent;
197
-
198
- function withClientHeaders(
199
- options?: RequestOptions,
200
- ): RequestOptions | undefined {
201
- const layer2Headers = stealthProfile
202
- ? generateLayer2Headers(stealthProfile)
203
- : undefined;
204
-
205
- if (!userAgent && !layer2Headers) {
206
- return options;
874
+ const warnOnce = (message: string) => {
875
+ if (warnedMessages.has(message)) {
876
+ return;
207
877
  }
878
+ warnedMessages.add(message);
879
+ warn(message);
880
+ };
208
881
 
209
- return {
210
- ...options,
211
- headers: {
212
- ...(userAgent ? { "User-Agent": userAgent } : {}),
213
- ...layer2Headers,
214
- ...options?.headers,
215
- },
216
- };
217
- }
882
+ async function request(
883
+ url: string,
884
+ method: string,
885
+ options: RequestOptions & { body?: unknown } = {},
886
+ ): Promise<HttpResponse> {
887
+ if (!baseUrl && !isAbsoluteUrl(url)) {
888
+ throw new TransportError(
889
+ "ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
890
+ { code: "transport_invalid_url" },
891
+ );
892
+ }
893
+ assertNoHttpTransportOverrides(options);
894
+ const headersOptions = withClientHeaders(
895
+ options,
896
+ clientOptions,
897
+ options.body,
898
+ );
899
+ const methodName = normalizeHttpMethod(method);
900
+ const explicitRetry = headersOptions.retry !== undefined;
901
+ const retryOptions =
902
+ normalizeRetryOptions(headersOptions.retry) ??
903
+ (explicitRetry
904
+ ? undefined
905
+ : createRetryOptions(HttpRetryPreset.TransportTransient));
906
+ if (retryOptions) validateUnsafeRetryMethods(retryOptions);
907
+ const retryEnabled = Boolean(
908
+ retryOptions &&
909
+ retryOptions.attempts > 1 &&
910
+ isMethodRetryable(methodName, retryOptions),
911
+ );
912
+ const statusRetryEnabled = Boolean(
913
+ retryEnabled &&
914
+ explicitRetry &&
915
+ retryOptions &&
916
+ retryOptions.statusCodes.length > 0 &&
917
+ headersOptions.throwOnHttpError !== false,
918
+ );
919
+ const attemptOptions: RequestOptions & { body?: unknown } =
920
+ statusRetryEnabled
921
+ ? { ...headersOptions, throwOnHttpError: false }
922
+ : headersOptions;
218
923
 
219
- function resolveRequestProxy(
220
- requestOptions?: RequestOptions,
221
- ): string | undefined {
222
- const resolvedProxy = resolveProxyConfig({
223
- proxy: requestOptions?.proxy ?? clientOptions.proxy,
224
- upstream: clientOptions.upstream,
225
- apifuseConfig: clientOptions.apifuseConfig,
226
- });
924
+ const executeOnce = (
925
+ proxyAttemptOffset = 0,
926
+ ): Promise<NativeHttpAttemptOutcome> =>
927
+ fetchNativeHttp(
928
+ baseUrl,
929
+ url,
930
+ methodName,
931
+ attemptOptions,
932
+ clientOptions,
933
+ warnOnce,
934
+ statusRetryEnabled ? retryOptions?.statusCodes : undefined,
935
+ proxyAttemptOffset,
936
+ );
227
937
 
228
- if (resolvedProxy.shouldWarn && !hasWarnedMissingProxy) {
229
- hasWarnedMissingProxy = true;
230
- warn(MISSING_PROXY_WARNING);
938
+ if (!retryEnabled || !retryOptions) {
939
+ const outcome = await executeOnce();
940
+ if (isHttpStatusOutcome(outcome)) {
941
+ throw toUpstreamHttpError(outcome.status);
942
+ }
943
+ return outcome;
944
+ }
945
+
946
+ let lastErrorCode: string | undefined;
947
+ let lastStatus: number | undefined;
948
+ for (let attempt = 1; attempt <= retryOptions.attempts; attempt += 1) {
949
+ try {
950
+ const outcome = await executeOnce(attempt - 1);
951
+ if (isHttpStatusOutcome(outcome)) {
952
+ lastStatus = outcome.status;
953
+ if (outcome.retryable && attempt < retryOptions.attempts) {
954
+ await sleep(
955
+ computeRetryDelayMs(retryOptions, attempt, outcome.headers),
956
+ );
957
+ continue;
958
+ }
959
+ throw toUpstreamHttpError(outcome.status);
960
+ }
961
+
962
+ const response = outcome;
963
+ if (
964
+ response.status >= 400 &&
965
+ headersOptions.throwOnHttpError !== false
966
+ ) {
967
+ throw toUpstreamHttpError(response.status);
968
+ }
969
+
970
+ if (attempt > 1) {
971
+ const summary: HttpRetrySummary = {
972
+ attempts: attempt,
973
+ retries: attempt - 1,
974
+ ...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
975
+ transport: "native",
976
+ ...(lastErrorCode ? { lastErrorCode } : {}),
977
+ ...(lastStatus ? { lastStatus } : {}),
978
+ };
979
+ clientOptions.onRetrySummary?.(summary);
980
+ }
981
+ return response;
982
+ } catch (error) {
983
+ lastErrorCode = retryErrorCode(error);
984
+ lastStatus = retryErrorStatus(error);
985
+ const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
986
+ if (
987
+ attempt < retryOptions.attempts &&
988
+ (explicitRetry || proxyUsed) &&
989
+ shouldRetryTransportError(error, retryOptions)
990
+ ) {
991
+ await sleep(computeRetryDelayMs(retryOptions, attempt));
992
+ continue;
993
+ }
994
+ throw error;
995
+ }
231
996
  }
232
997
 
233
- return resolvedProxy.url;
998
+ throw new TransportError("HTTP retry exhausted without a terminal result", {
999
+ code: "retry_exhausted",
1000
+ });
1001
+ }
1002
+
1003
+ async function streamRequest(
1004
+ url: string,
1005
+ method: string,
1006
+ options: RequestOptions & { body?: unknown } = {},
1007
+ ): Promise<HttpStreamResponse> {
1008
+ if (!baseUrl && !isAbsoluteUrl(url)) {
1009
+ throw new TransportError(
1010
+ "ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared",
1011
+ { code: "transport_invalid_url" },
1012
+ );
1013
+ }
1014
+ assertNoHttpTransportOverrides(options);
1015
+ const headersOptions = withClientHeaders(
1016
+ options,
1017
+ clientOptions,
1018
+ options.body,
1019
+ );
1020
+ const methodName = normalizeHttpMethod(method);
1021
+ return fetchNativeHttpStream(
1022
+ baseUrl,
1023
+ url,
1024
+ methodName,
1025
+ headersOptions,
1026
+ clientOptions,
1027
+ warnOnce,
1028
+ );
234
1029
  }
235
1030
 
236
1031
  return {
237
- request: (url, options: RequestWithMethodOptions = {}) =>
238
- doRequest(
239
- baseUrl,
240
- url,
241
- options.method ?? "GET",
242
- resolveRequestProxy(options),
243
- withClientHeaders(options),
244
- ),
245
- get: (url, options) =>
246
- doRequest(
247
- baseUrl,
248
- url,
249
- "GET",
250
- resolveRequestProxy(options),
251
- withClientHeaders(options),
252
- ),
253
- post: (url, body, options) =>
254
- doRequest(baseUrl, url, "POST", resolveRequestProxy(options), {
255
- ...withClientHeaders(options),
256
- body,
257
- }),
258
- put: (url, body, options) =>
259
- doRequest(baseUrl, url, "PUT", resolveRequestProxy(options), {
260
- ...withClientHeaders(options),
261
- body,
262
- }),
263
- delete: (url, options) =>
264
- doRequest(
265
- baseUrl,
266
- url,
267
- "DELETE",
268
- resolveRequestProxy(options),
269
- withClientHeaders(options),
270
- ),
1032
+ request: async (url, options: RequestWithMethodOptions = {}) =>
1033
+ request(url, options.method ?? "GET", options),
1034
+ 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 }),
1039
+ delete: async (url, options) => request(url, "DELETE", options),
1040
+ stream: async (url, options: RequestWithMethodOptions = {}) =>
1041
+ streamRequest(url, options.method ?? "GET", options),
1042
+ sse: async (
1043
+ url,
1044
+ options: RequestWithMethodOptions = {},
1045
+ ): Promise<AsyncIterable<SseMessage>> => {
1046
+ const headers = {
1047
+ Accept: "text/event-stream",
1048
+ ...options.headers,
1049
+ };
1050
+ const response = await streamRequest(url, options.method ?? "GET", {
1051
+ ...options,
1052
+ headers,
1053
+ });
1054
+ return parseSseStream(response.body);
1055
+ },
271
1056
  };
272
1057
  }