@apifuse/provider-sdk 2.1.0-beta.3 → 2.1.0-beta.4
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.
- package/AUTHORING.md +163 -8
- package/CHANGELOG.md +8 -1
- package/README.md +17 -16
- package/SUBMISSION.md +4 -4
- package/bin/apifuse-dev.ts +12 -5
- package/bin/apifuse-pack-check.ts +9 -2
- package/bin/apifuse-pack-smoke.ts +127 -6
- package/bin/apifuse-perf.ts +19 -15
- package/bin/apifuse-record.ts +41 -53
- package/bin/apifuse-submit-check.ts +179 -7
- package/bin/apifuse.ts +1 -1
- package/package.json +17 -8
- package/src/choice-token.ts +164 -0
- package/src/cli/commands.ts +1 -3
- package/src/cli/create.ts +159 -50
- package/src/cli/templates/provider/README.md.tpl +24 -7
- package/src/cli/templates/provider/dev.ts.tpl +1 -1
- package/src/cli/templates/provider/domain/README.md.tpl +3 -0
- package/src/cli/templates/provider/index.ts.tpl +5 -47
- package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
- package/src/cli/templates/provider/meta.ts.tpl +7 -0
- package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
- package/src/cli/templates/provider/operations/ping.ts.tpl +23 -0
- package/src/cli/templates/provider/schemas/ping.ts.tpl +16 -0
- package/src/cli/templates/provider/start.ts.tpl +1 -1
- package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
- package/src/config/loader.ts +1206 -9
- package/src/define.ts +1618 -104
- package/src/errors.ts +12 -0
- package/src/i18n/catalog.ts +121 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/keys.ts +64 -0
- package/src/index.ts +149 -8
- package/src/lint.ts +297 -42
- package/src/observability.ts +41 -0
- package/src/provider.ts +60 -3
- package/src/public-schema-field-lint.ts +237 -0
- package/src/runtime/auth-flow.ts +7 -0
- package/src/runtime/browser.ts +77 -21
- package/src/runtime/cache.ts +582 -0
- package/src/runtime/executor.ts +13 -1
- package/src/runtime/http.ts +939 -195
- package/src/runtime/insights.ts +11 -11
- package/src/runtime/instrumentation.ts +12 -4
- package/src/runtime/key-derivation.ts +1 -1
- package/src/runtime/keyring.ts +4 -3
- package/src/runtime/proxy-errors.ts +132 -0
- package/src/runtime/proxy-telemetry.ts +253 -0
- package/src/runtime/request-options.ts +66 -0
- package/src/runtime/state.ts +76 -0
- package/src/runtime/stealth.ts +1145 -0
- package/src/runtime/stt.ts +629 -0
- package/src/schema.ts +363 -1
- package/src/server/serve.ts +816 -58
- package/src/server/types.ts +35 -0
- package/src/stream.ts +210 -0
- package/src/testing/run.ts +17 -4
- package/src/types.ts +869 -50
- package/src/runtime/tls.ts +0 -434
- package/src/types/playwright-stealth.d.ts +0 -9
package/src/config/loader.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
4
|
-
import
|
|
5
|
+
import Redis from "ioredis";
|
|
6
|
+
|
|
7
|
+
import type { ProviderProxyPolicy, TraceConfig } from "../types";
|
|
8
|
+
|
|
9
|
+
export const SMARTPROXY_APP_KEY_ENV = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
|
|
10
|
+
export const SMARTPROXY_MAX_LIFETIME_MINUTES = 2000;
|
|
11
|
+
export const DEFAULT_SMARTPROXY_POOL_SIZE = 20;
|
|
12
|
+
export const SMARTPROXY_MAX_POOL_SIZE = 20;
|
|
13
|
+
export const DEFAULT_PROXY_PROVIDER_ENV = "APIFUSE__PROXY__PROVIDER";
|
|
14
|
+
export const DEFAULT_PROXY_COUNTRY_ENV = "APIFUSE__PROXY__DEFAULT_COUNTRY";
|
|
15
|
+
export const DEFAULT_PROXY_LIFETIME_ENV =
|
|
16
|
+
"APIFUSE__PROXY__DEFAULT_LIFETIME_MINUTES";
|
|
17
|
+
export const PROVIDER_CACHE_REDIS_URL_ENV =
|
|
18
|
+
"APIFUSE__PROVIDER__CACHE_REDIS_URL";
|
|
19
|
+
export const REDIS_URL_ENV = "APIFUSE__REDIS__URL";
|
|
5
20
|
|
|
6
21
|
export type ProxyOptions = {
|
|
7
22
|
url: string;
|
|
@@ -32,20 +47,337 @@ export type ApiFuseConfig = {
|
|
|
32
47
|
|
|
33
48
|
export type ProxyResolutionOptions = {
|
|
34
49
|
proxy?: string;
|
|
35
|
-
upstream?: { proxy?: boolean };
|
|
50
|
+
upstream?: { proxy?: boolean | ProviderProxyPolicy };
|
|
36
51
|
apifuseConfig?: Pick<ApiFuseConfig, "proxy">;
|
|
52
|
+
proxyPolicy?: ProviderProxyPolicy;
|
|
53
|
+
affinityKey?: string;
|
|
54
|
+
/** Zero-based proxy-pool attempt index used by SDK transports for failover. */
|
|
55
|
+
proxyAttempt?: number;
|
|
56
|
+
telemetry?: ProxyTelemetrySink;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type ProxyCacheStatus =
|
|
60
|
+
| "memory_hit"
|
|
61
|
+
| "redis_hit"
|
|
62
|
+
| "allocator"
|
|
63
|
+
| "soft_stale_refresh"
|
|
64
|
+
| "lock_wait"
|
|
65
|
+
| "redis_error"
|
|
66
|
+
| "redis_corrupt"
|
|
67
|
+
| "disabled";
|
|
68
|
+
|
|
69
|
+
export type SmartproxyAllocatorBodyClass =
|
|
70
|
+
| "network_error"
|
|
71
|
+
| "http_error"
|
|
72
|
+
| "empty"
|
|
73
|
+
| "json_without_proxies"
|
|
74
|
+
| "text_without_proxies"
|
|
75
|
+
| "usable_proxy_endpoints";
|
|
76
|
+
|
|
77
|
+
export type ProxyResolutionTelemetryEvent = {
|
|
78
|
+
provider: "smartproxy";
|
|
79
|
+
cacheStatus: ProxyCacheStatus;
|
|
80
|
+
cacheHit: boolean;
|
|
81
|
+
resolutionMs: number;
|
|
82
|
+
allocatorMs?: number;
|
|
83
|
+
allocatorStatus?: number;
|
|
84
|
+
allocatorBodyClass?: SmartproxyAllocatorBodyClass;
|
|
85
|
+
allocatorAttempts?: number;
|
|
86
|
+
lockWaitMs?: number;
|
|
87
|
+
redisReadMs?: number;
|
|
88
|
+
redisWriteMs?: number;
|
|
89
|
+
poolAgeMs?: number;
|
|
90
|
+
poolExpiresInMs?: number;
|
|
91
|
+
attempts: number;
|
|
92
|
+
refreshes?: number;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type ProxyAttemptTelemetryEvent = {
|
|
96
|
+
provider: "smartproxy";
|
|
97
|
+
attempt: number;
|
|
98
|
+
poolIndex?: number;
|
|
99
|
+
proxyHash?: string;
|
|
100
|
+
outcome: "ok" | "error";
|
|
101
|
+
errorCode?: string;
|
|
102
|
+
status?: number;
|
|
103
|
+
durationMs?: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type ProxyTelemetrySink = {
|
|
107
|
+
recordProxyResolution(event: ProxyResolutionTelemetryEvent): void;
|
|
108
|
+
recordProxyAttempt?(event: ProxyAttemptTelemetryEvent): void;
|
|
37
109
|
};
|
|
38
110
|
|
|
39
111
|
export type ResolvedProxyConfig = {
|
|
40
112
|
shouldWarn: boolean;
|
|
41
113
|
url?: string;
|
|
114
|
+
source?: "explicit" | "env" | "config" | "smartproxy-allocator";
|
|
115
|
+
diagnostics?: Record<string, string | number | boolean>;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export class ProxyResolutionError extends Error {
|
|
119
|
+
readonly code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED";
|
|
120
|
+
readonly telemetry?: ProxyResolutionTelemetryEvent;
|
|
121
|
+
|
|
122
|
+
constructor(
|
|
123
|
+
code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED",
|
|
124
|
+
message: string,
|
|
125
|
+
options?: { cause?: unknown; telemetry?: ProxyResolutionTelemetryEvent },
|
|
126
|
+
) {
|
|
127
|
+
super(message, options);
|
|
128
|
+
this.name = "ProxyResolutionError";
|
|
129
|
+
this.code = code;
|
|
130
|
+
this.telemetry = options?.telemetry;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type CachedProxyPool = {
|
|
135
|
+
urls: string[];
|
|
136
|
+
allocatedAt: number;
|
|
137
|
+
refreshAfter: number;
|
|
138
|
+
expiresAt: number;
|
|
139
|
+
diagnostics?: Record<string, string | number | boolean>;
|
|
42
140
|
};
|
|
43
141
|
|
|
142
|
+
type SmartproxyAllocationResult = {
|
|
143
|
+
pool: CachedProxyPool;
|
|
144
|
+
telemetry: ProxyResolutionTelemetryEvent;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type ProxyRedisClient = Pick<
|
|
148
|
+
Redis,
|
|
149
|
+
"connect" | "del" | "eval" | "get" | "on" | "pttl" | "set" | "status"
|
|
150
|
+
>;
|
|
151
|
+
|
|
152
|
+
const proxyCache = new Map<string, CachedProxyPool>();
|
|
153
|
+
const proxyInflight = new Map<string, Promise<SmartproxyAllocationResult>>();
|
|
154
|
+
const invalidatedProxyKeys = new Map<string, number>();
|
|
155
|
+
const redisClients = new Map<string, ProxyRedisClient>();
|
|
156
|
+
let proxyRedisForTests: ProxyRedisClient | undefined;
|
|
157
|
+
let smartproxyAllocatorDeadlineMsForTests: number | undefined;
|
|
158
|
+
|
|
159
|
+
const PROXY_CACHE_PREFIX = "apifuse:proxy:smartproxy:v1";
|
|
160
|
+
const REDIS_TIMEOUT_MS = 150;
|
|
161
|
+
const SMARTPROXY_LOCK_TTL_MS = 10_000;
|
|
162
|
+
const SMARTPROXY_LOCK_POLL_MAX_MS = 9_000;
|
|
163
|
+
const SMARTPROXY_DEADLINE_MARGIN_MS = 1_000;
|
|
164
|
+
const SMARTPROXY_INVALIDATION_SKIP_REDIS_MS = 30_000;
|
|
165
|
+
const SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS = 3;
|
|
166
|
+
const SMARTPROXY_ALLOCATOR_DEADLINE_MS =
|
|
167
|
+
SMARTPROXY_LOCK_TTL_MS - SMARTPROXY_DEADLINE_MARGIN_MS;
|
|
168
|
+
const SMARTPROXY_ALLOCATOR_RETRY_BASE_MS = 25;
|
|
169
|
+
// Smartproxy API extraction returns fresh IP:port candidates; the `life`
|
|
170
|
+
// parameter controls session duration intent, not a hard endpoint lease. Keep
|
|
171
|
+
// successful extractions only briefly to collapse concurrent requests and avoid
|
|
172
|
+
// reusing stale raw CONNECT endpoints as if they were valid for `life` minutes.
|
|
173
|
+
const SMARTPROXY_EXTRACTION_CACHE_TTL_MS = 15_000;
|
|
174
|
+
const SMARTPROXY_EXTRACTION_SOFT_REFRESH_MS = 10_000;
|
|
175
|
+
|
|
176
|
+
function redisUrlFromEnv(): string | undefined {
|
|
177
|
+
return (
|
|
178
|
+
process.env[PROVIDER_CACHE_REDIS_URL_ENV]?.trim() ||
|
|
179
|
+
process.env[REDIS_URL_ENV]?.trim() ||
|
|
180
|
+
undefined
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @internal Test-only hook for exercising shared proxy-cache behavior. */
|
|
185
|
+
export function __setProxyRedisForTests(
|
|
186
|
+
redis: ProxyRedisClient | undefined,
|
|
187
|
+
): void {
|
|
188
|
+
proxyRedisForTests = redis;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function __setSmartproxyAllocatorDeadlineMsForTests(
|
|
192
|
+
deadlineMs: number | undefined,
|
|
193
|
+
): void {
|
|
194
|
+
smartproxyAllocatorDeadlineMsForTests = deadlineMs;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getProxyRedis(): ProxyRedisClient | undefined {
|
|
198
|
+
if (proxyRedisForTests) return proxyRedisForTests;
|
|
199
|
+
const redisUrl = redisUrlFromEnv();
|
|
200
|
+
if (!redisUrl) return undefined;
|
|
201
|
+
const existing = redisClients.get(redisUrl);
|
|
202
|
+
if (existing) return existing;
|
|
203
|
+
|
|
204
|
+
const redis = new Redis(redisUrl, {
|
|
205
|
+
connectTimeout: REDIS_TIMEOUT_MS,
|
|
206
|
+
enableOfflineQueue: false,
|
|
207
|
+
lazyConnect: true,
|
|
208
|
+
maxRetriesPerRequest: 0,
|
|
209
|
+
retryStrategy: () => null,
|
|
210
|
+
});
|
|
211
|
+
redis.on("error", () => {
|
|
212
|
+
// Fail-open to allocator/memory; Redis connectivity must not choose direct egress.
|
|
213
|
+
});
|
|
214
|
+
redisClients.set(redisUrl, redis);
|
|
215
|
+
return redis;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function withRedisTimeout<T>(
|
|
219
|
+
operation: () => Promise<T>,
|
|
220
|
+
): Promise<T | undefined> {
|
|
221
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
222
|
+
try {
|
|
223
|
+
const timeout = new Promise<undefined>((resolve) => {
|
|
224
|
+
timeoutId = setTimeout(() => resolve(undefined), REDIS_TIMEOUT_MS);
|
|
225
|
+
});
|
|
226
|
+
return await Promise.race([operation().catch(() => undefined), timeout]);
|
|
227
|
+
} finally {
|
|
228
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function redisStatus(redis: ProxyRedisClient): string {
|
|
233
|
+
return redis.status;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function ensureRedisReady(redis: ProxyRedisClient): Promise<boolean> {
|
|
237
|
+
if (redisStatus(redis) === "ready") return true;
|
|
238
|
+
if (redisStatus(redis) === "wait" || redisStatus(redis) === "end") {
|
|
239
|
+
const connected = await withRedisTimeout(async () => {
|
|
240
|
+
await redis.connect();
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
return connected === true && redisStatus(redis) === "ready";
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function sha256(value: string): string {
|
|
249
|
+
return createHash("sha256").update(value).digest("hex");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function smartproxyRedisPoolKey(cacheKey: string): string {
|
|
253
|
+
return `${PROXY_CACHE_PREFIX}:pool:${sha256(cacheKey)}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function smartproxyRedisLockKey(cacheKey: string): string {
|
|
257
|
+
return `${PROXY_CACHE_PREFIX}:lock:${sha256(cacheKey)}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isFresh(pool: CachedProxyPool, now: number): boolean {
|
|
261
|
+
return pool.expiresAt > now;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function shouldSoftRefresh(pool: CachedProxyPool, now: number): boolean {
|
|
265
|
+
return isFresh(pool, now) && pool.refreshAfter <= now;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function telemetryForPool(
|
|
269
|
+
pool: CachedProxyPool,
|
|
270
|
+
cacheStatus: ProxyCacheStatus,
|
|
271
|
+
startedAt: number,
|
|
272
|
+
extra: Partial<ProxyResolutionTelemetryEvent> = {},
|
|
273
|
+
): ProxyResolutionTelemetryEvent {
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
return {
|
|
276
|
+
provider: "smartproxy",
|
|
277
|
+
cacheStatus,
|
|
278
|
+
cacheHit: cacheStatus !== "allocator",
|
|
279
|
+
resolutionMs: Math.max(0, now - startedAt),
|
|
280
|
+
poolAgeMs: Math.max(0, now - pool.allocatedAt),
|
|
281
|
+
poolExpiresInMs: Math.max(0, pool.expiresAt - now),
|
|
282
|
+
attempts: 1,
|
|
283
|
+
...extra,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function telemetryForFailure(
|
|
288
|
+
cacheStatus: ProxyCacheStatus,
|
|
289
|
+
startedAt: number,
|
|
290
|
+
extra: Partial<ProxyResolutionTelemetryEvent> = {},
|
|
291
|
+
): ProxyResolutionTelemetryEvent {
|
|
292
|
+
return {
|
|
293
|
+
provider: "smartproxy",
|
|
294
|
+
cacheStatus,
|
|
295
|
+
cacheHit: false,
|
|
296
|
+
resolutionMs: Math.max(0, Date.now() - startedAt),
|
|
297
|
+
attempts: 1,
|
|
298
|
+
...extra,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
303
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function toProxyDiagnostics(
|
|
307
|
+
value: unknown,
|
|
308
|
+
): Record<string, string | number | boolean> | undefined {
|
|
309
|
+
if (!isRecord(value)) return undefined;
|
|
310
|
+
const diagnostics: Record<string, string | number | boolean> = {};
|
|
311
|
+
for (const [key, item] of Object.entries(value)) {
|
|
312
|
+
if (
|
|
313
|
+
typeof item === "string" ||
|
|
314
|
+
typeof item === "number" ||
|
|
315
|
+
typeof item === "boolean"
|
|
316
|
+
) {
|
|
317
|
+
diagnostics[key] = item;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return Object.keys(diagnostics).length > 0 ? diagnostics : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function safeParseSmartproxyPool(raw: string | null): CachedProxyPool | null {
|
|
324
|
+
if (!raw) return null;
|
|
325
|
+
try {
|
|
326
|
+
const parsed: unknown = JSON.parse(raw);
|
|
327
|
+
if (!isRecord(parsed)) return null;
|
|
328
|
+
const record = parsed;
|
|
329
|
+
if (
|
|
330
|
+
record.version !== 1 ||
|
|
331
|
+
record.proxyProvider !== "smartproxy" ||
|
|
332
|
+
!Array.isArray(record.urls) ||
|
|
333
|
+
typeof record.allocatedAt !== "number" ||
|
|
334
|
+
typeof record.refreshAfter !== "number" ||
|
|
335
|
+
typeof record.expiresAt !== "number"
|
|
336
|
+
) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const urls = record.urls.filter(
|
|
340
|
+
(url): url is string => typeof url === "string" && url.startsWith("http"),
|
|
341
|
+
);
|
|
342
|
+
if (urls.length === 0) return null;
|
|
343
|
+
return {
|
|
344
|
+
urls,
|
|
345
|
+
allocatedAt: record.allocatedAt,
|
|
346
|
+
refreshAfter: record.refreshAfter,
|
|
347
|
+
expiresAt: record.expiresAt,
|
|
348
|
+
diagnostics: toProxyDiagnostics(record.diagnostics),
|
|
349
|
+
};
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function serializeSmartproxyPool(pool: CachedProxyPool): string {
|
|
356
|
+
return JSON.stringify({
|
|
357
|
+
version: 1,
|
|
358
|
+
proxyProvider: "smartproxy",
|
|
359
|
+
urls: pool.urls,
|
|
360
|
+
allocatedAt: pool.allocatedAt,
|
|
361
|
+
refreshAfter: pool.refreshAfter,
|
|
362
|
+
expiresAt: pool.expiresAt,
|
|
363
|
+
diagnostics: pool.diagnostics,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
44
367
|
function normalizeProxyUrl(url?: string): string | undefined {
|
|
45
368
|
const normalized = url?.trim();
|
|
46
369
|
return normalized ? applyStickyProxySession(normalized) : undefined;
|
|
47
370
|
}
|
|
48
371
|
|
|
372
|
+
function readPositiveIntegerEnv(name: string): string | undefined {
|
|
373
|
+
const raw = process.env[name]?.trim();
|
|
374
|
+
if (!raw) return undefined;
|
|
375
|
+
if (!/^[1-9]\d*$/.test(raw)) {
|
|
376
|
+
throw new Error(`${name} must be a positive integer`);
|
|
377
|
+
}
|
|
378
|
+
return raw;
|
|
379
|
+
}
|
|
380
|
+
|
|
49
381
|
function applyStickyProxySession(proxyUrl: string): string {
|
|
50
382
|
let parsed: URL;
|
|
51
383
|
try {
|
|
@@ -65,9 +397,10 @@ function applyStickyProxySession(proxyUrl: string): string {
|
|
|
65
397
|
|
|
66
398
|
const username = decodeURIComponent(parsed.username);
|
|
67
399
|
const sessionId =
|
|
68
|
-
process.env.
|
|
69
|
-
const sessionDuration =
|
|
70
|
-
|
|
400
|
+
process.env.APIFUSE__PROXY__SESSION_ID?.trim() || "apifuse-shared";
|
|
401
|
+
const sessionDuration = readPositiveIntegerEnv(
|
|
402
|
+
"APIFUSE__PROXY__SESSION_DURATION",
|
|
403
|
+
);
|
|
71
404
|
const stickyUsername = host.includes("smartproxy")
|
|
72
405
|
? buildSmartproxyUsername(username, sessionId, sessionDuration)
|
|
73
406
|
: buildDecodoUsername(username, sessionId, sessionDuration ?? "60");
|
|
@@ -108,8 +441,8 @@ function buildDecodoUsername(
|
|
|
108
441
|
|
|
109
442
|
function syncProxyEnv(config: ApiFuseConfig): void {
|
|
110
443
|
const configProxyUrl = normalizeProxyUrl(config.proxy?.url);
|
|
111
|
-
if (!process.env.
|
|
112
|
-
process.env.
|
|
444
|
+
if (!process.env.APIFUSE__PROXY__URL && configProxyUrl) {
|
|
445
|
+
process.env.APIFUSE__PROXY__URL = configProxyUrl;
|
|
113
446
|
}
|
|
114
447
|
}
|
|
115
448
|
|
|
@@ -121,11 +454,18 @@ export function resolveProxyConfig(
|
|
|
121
454
|
return { shouldWarn: false, url: explicitProxyUrl };
|
|
122
455
|
}
|
|
123
456
|
|
|
124
|
-
|
|
457
|
+
const policy = resolvePolicy(options);
|
|
458
|
+
if (policy?.mode === "disabled") {
|
|
125
459
|
return { shouldWarn: false };
|
|
126
460
|
}
|
|
127
461
|
|
|
128
|
-
const
|
|
462
|
+
const legacyProxyRequested =
|
|
463
|
+
options.upstream?.proxy === true || (!policy && options.upstream?.proxy);
|
|
464
|
+
if (!legacyProxyRequested) {
|
|
465
|
+
return { shouldWarn: false };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const envProxyUrl = normalizeProxyUrl(process.env.APIFUSE__PROXY__URL);
|
|
129
469
|
if (envProxyUrl) {
|
|
130
470
|
return { shouldWarn: false, url: envProxyUrl };
|
|
131
471
|
}
|
|
@@ -140,6 +480,863 @@ export function resolveProxyConfig(
|
|
|
140
480
|
return { shouldWarn: true };
|
|
141
481
|
}
|
|
142
482
|
|
|
483
|
+
export async function resolveProxyConfigAsync(
|
|
484
|
+
options: ProxyResolutionOptions = {},
|
|
485
|
+
): Promise<ResolvedProxyConfig> {
|
|
486
|
+
const explicitProxyUrl = normalizeProxyUrl(options.proxy);
|
|
487
|
+
if (explicitProxyUrl) {
|
|
488
|
+
return { shouldWarn: false, url: explicitProxyUrl, source: "explicit" };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const policy = resolvePolicy(options);
|
|
492
|
+
if (!policy) {
|
|
493
|
+
return resolveProxyConfig(options);
|
|
494
|
+
}
|
|
495
|
+
if (policy.mode === "disabled") {
|
|
496
|
+
return { shouldWarn: false };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const provider = resolveProxyProvider(policy);
|
|
500
|
+
if (provider !== "smartproxy") {
|
|
501
|
+
return resolveProxyConfig({
|
|
502
|
+
...options,
|
|
503
|
+
upstream: { proxy: true },
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
|
|
508
|
+
if (!appKey) {
|
|
509
|
+
if (policy.mode === "required") {
|
|
510
|
+
throw new ProxyResolutionError(
|
|
511
|
+
"PROXY_REQUIRED",
|
|
512
|
+
`Smartproxy egress is required but ${SMARTPROXY_APP_KEY_ENV} is not configured.`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
return { shouldWarn: true };
|
|
516
|
+
}
|
|
517
|
+
const lifetimeMinutes = resolveSmartproxyLifetime(policy);
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const allocated = await allocateSmartproxy(
|
|
521
|
+
policy,
|
|
522
|
+
appKey,
|
|
523
|
+
lifetimeMinutes,
|
|
524
|
+
options.affinityKey,
|
|
525
|
+
);
|
|
526
|
+
options.telemetry?.recordProxyResolution(allocated.telemetry);
|
|
527
|
+
const poolIndex = selectProxyPoolIndex(
|
|
528
|
+
allocated.pool.urls.length,
|
|
529
|
+
options.proxyAttempt,
|
|
530
|
+
);
|
|
531
|
+
return {
|
|
532
|
+
shouldWarn: false,
|
|
533
|
+
url: allocated.pool.urls[poolIndex],
|
|
534
|
+
source: "smartproxy-allocator",
|
|
535
|
+
diagnostics: {
|
|
536
|
+
...allocated.pool.diagnostics,
|
|
537
|
+
poolSize: allocated.pool.urls.length,
|
|
538
|
+
poolIndex,
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (error instanceof ProxyResolutionError && error.telemetry) {
|
|
543
|
+
options.telemetry?.recordProxyResolution(error.telemetry);
|
|
544
|
+
}
|
|
545
|
+
if (policy.mode === "required") {
|
|
546
|
+
throw error instanceof ProxyResolutionError
|
|
547
|
+
? error
|
|
548
|
+
: new ProxyResolutionError(
|
|
549
|
+
"PROXY_ALLOCATION_FAILED",
|
|
550
|
+
"Smartproxy allocator failed for required proxy egress.",
|
|
551
|
+
{ cause: error },
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
return { shouldWarn: true };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function resolvePolicy(
|
|
559
|
+
options: ProxyResolutionOptions,
|
|
560
|
+
): ProviderProxyPolicy | undefined {
|
|
561
|
+
if (options.proxyPolicy) {
|
|
562
|
+
return options.proxyPolicy;
|
|
563
|
+
}
|
|
564
|
+
const upstreamProxy = options.upstream?.proxy;
|
|
565
|
+
if (upstreamProxy && typeof upstreamProxy === "object") {
|
|
566
|
+
return upstreamProxy;
|
|
567
|
+
}
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function resolveProxyProvider(policy: ProviderProxyPolicy): string {
|
|
572
|
+
return (
|
|
573
|
+
policy.provider ??
|
|
574
|
+
process.env[DEFAULT_PROXY_PROVIDER_ENV]?.trim().toLowerCase() ??
|
|
575
|
+
"custom"
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function resolveSmartproxyCountry(
|
|
580
|
+
policy: ProviderProxyPolicy,
|
|
581
|
+
): string | undefined {
|
|
582
|
+
return (
|
|
583
|
+
policy.geo?.country ??
|
|
584
|
+
process.env[DEFAULT_PROXY_COUNTRY_ENV]?.trim().toUpperCase() ??
|
|
585
|
+
undefined
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function resolveSmartproxyLifetime(policy: ProviderProxyPolicy): number {
|
|
590
|
+
const configuredLifetime =
|
|
591
|
+
policy.session?.lifetimeMinutes ??
|
|
592
|
+
readPositiveNumberEnv(DEFAULT_PROXY_LIFETIME_ENV, 30);
|
|
593
|
+
return Math.min(
|
|
594
|
+
SMARTPROXY_MAX_LIFETIME_MINUTES,
|
|
595
|
+
Math.max(1, Math.floor(configuredLifetime)),
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function readPositiveNumberEnv(name: string, fallback: number): number {
|
|
600
|
+
const raw = process.env[name]?.trim();
|
|
601
|
+
if (!raw) return fallback;
|
|
602
|
+
const parsed = Number(raw);
|
|
603
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
604
|
+
throw new Error(`${name} must be a positive number`);
|
|
605
|
+
}
|
|
606
|
+
return parsed;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function resolveSmartproxyPoolSize(policy: ProviderProxyPolicy): number {
|
|
610
|
+
return Math.min(
|
|
611
|
+
SMARTPROXY_MAX_POOL_SIZE,
|
|
612
|
+
Math.max(
|
|
613
|
+
1,
|
|
614
|
+
Math.floor(policy.session?.poolSize ?? DEFAULT_SMARTPROXY_POOL_SIZE),
|
|
615
|
+
),
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function selectProxyPoolIndex(poolSize: number, attempt = 0): number {
|
|
620
|
+
if (poolSize <= 1) {
|
|
621
|
+
return 0;
|
|
622
|
+
}
|
|
623
|
+
return Math.max(0, Math.floor(attempt)) % poolSize;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function buildSmartproxyCacheKey(
|
|
627
|
+
policy: ProviderProxyPolicy,
|
|
628
|
+
affinityKey: string | undefined,
|
|
629
|
+
lifetimeMinutes: number,
|
|
630
|
+
): string {
|
|
631
|
+
const poolSize = resolveSmartproxyPoolSize(policy);
|
|
632
|
+
return JSON.stringify({
|
|
633
|
+
provider: "smartproxy",
|
|
634
|
+
country: resolveSmartproxyCountry(policy),
|
|
635
|
+
affinity: policy.session?.affinity ?? "request",
|
|
636
|
+
affinityKey:
|
|
637
|
+
(policy.session?.affinity ?? "request") === "request"
|
|
638
|
+
? undefined
|
|
639
|
+
: affinityKey,
|
|
640
|
+
lifetimeMinutes,
|
|
641
|
+
poolSize,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function allocateSmartproxy(
|
|
646
|
+
policy: ProviderProxyPolicy,
|
|
647
|
+
appKey: string,
|
|
648
|
+
lifetimeMinutes: number,
|
|
649
|
+
affinityKey: string | undefined,
|
|
650
|
+
): Promise<SmartproxyAllocationResult> {
|
|
651
|
+
const cacheKey = buildSmartproxyCacheKey(
|
|
652
|
+
policy,
|
|
653
|
+
affinityKey,
|
|
654
|
+
lifetimeMinutes,
|
|
655
|
+
);
|
|
656
|
+
const startedAt = Date.now();
|
|
657
|
+
const now = startedAt;
|
|
658
|
+
const invalidatedUntil = invalidatedProxyKeys.get(cacheKey) ?? 0;
|
|
659
|
+
const skipCached = invalidatedUntil > now;
|
|
660
|
+
const cached = proxyCache.get(cacheKey);
|
|
661
|
+
if (!skipCached && cached && isFresh(cached, now)) {
|
|
662
|
+
if (shouldSoftRefresh(cached, now)) {
|
|
663
|
+
void refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes);
|
|
664
|
+
return {
|
|
665
|
+
pool: cached,
|
|
666
|
+
telemetry: telemetryForPool(cached, "soft_stale_refresh", startedAt, {
|
|
667
|
+
refreshes: 1,
|
|
668
|
+
}),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
pool: cached,
|
|
673
|
+
telemetry: telemetryForPool(cached, "memory_hit", startedAt),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (!skipCached) {
|
|
678
|
+
const redisResult = await readSmartproxyRedisPool(cacheKey, startedAt);
|
|
679
|
+
if (redisResult) return redisResult;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const existingInflight = proxyInflight.get(cacheKey);
|
|
683
|
+
if (existingInflight) {
|
|
684
|
+
const result = await existingInflight;
|
|
685
|
+
return {
|
|
686
|
+
pool: result.pool,
|
|
687
|
+
telemetry: telemetryForPool(result.pool, "lock_wait", startedAt, {
|
|
688
|
+
lockWaitMs: Math.max(0, Date.now() - startedAt),
|
|
689
|
+
}),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const promise = allocateSmartproxyShared(
|
|
694
|
+
cacheKey,
|
|
695
|
+
policy,
|
|
696
|
+
appKey,
|
|
697
|
+
lifetimeMinutes,
|
|
698
|
+
startedAt,
|
|
699
|
+
).finally(() => {
|
|
700
|
+
proxyInflight.delete(cacheKey);
|
|
701
|
+
});
|
|
702
|
+
proxyInflight.set(cacheKey, promise);
|
|
703
|
+
const result = await promise;
|
|
704
|
+
invalidatedProxyKeys.delete(cacheKey);
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function readSmartproxyRedisPool(
|
|
709
|
+
cacheKey: string,
|
|
710
|
+
startedAt: number,
|
|
711
|
+
): Promise<SmartproxyAllocationResult | null> {
|
|
712
|
+
const redis = getProxyRedis();
|
|
713
|
+
if (!redis || !(await ensureRedisReady(redis))) return null;
|
|
714
|
+
const redisStartedAt = Date.now();
|
|
715
|
+
const raw = await withRedisTimeout(() =>
|
|
716
|
+
redis.get(smartproxyRedisPoolKey(cacheKey)),
|
|
717
|
+
);
|
|
718
|
+
const redisReadMs = Math.max(0, Date.now() - redisStartedAt);
|
|
719
|
+
if (typeof raw !== "string") return null;
|
|
720
|
+
const pool = safeParseSmartproxyPool(raw);
|
|
721
|
+
if (!pool) {
|
|
722
|
+
await withRedisTimeout(() => redis.del(smartproxyRedisPoolKey(cacheKey)));
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
const now = Date.now();
|
|
726
|
+
if (!isFresh(pool, now)) return null;
|
|
727
|
+
proxyCache.set(cacheKey, pool);
|
|
728
|
+
return {
|
|
729
|
+
pool,
|
|
730
|
+
telemetry: telemetryForPool(pool, "redis_hit", startedAt, { redisReadMs }),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function refreshSmartproxyPool(
|
|
735
|
+
cacheKey: string,
|
|
736
|
+
policy: ProviderProxyPolicy,
|
|
737
|
+
appKey: string,
|
|
738
|
+
lifetimeMinutes: number,
|
|
739
|
+
): Promise<void> {
|
|
740
|
+
try {
|
|
741
|
+
await allocateSmartproxyShared(
|
|
742
|
+
cacheKey,
|
|
743
|
+
policy,
|
|
744
|
+
appKey,
|
|
745
|
+
lifetimeMinutes,
|
|
746
|
+
Date.now(),
|
|
747
|
+
{
|
|
748
|
+
background: true,
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
} catch {
|
|
752
|
+
// Soft refresh is opportunistic; current fresh pool remains usable.
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function allocateSmartproxyShared(
|
|
757
|
+
cacheKey: string,
|
|
758
|
+
policy: ProviderProxyPolicy,
|
|
759
|
+
appKey: string,
|
|
760
|
+
lifetimeMinutes: number,
|
|
761
|
+
startedAt: number,
|
|
762
|
+
options: { background?: boolean } = {},
|
|
763
|
+
): Promise<SmartproxyAllocationResult> {
|
|
764
|
+
const redis = getProxyRedis();
|
|
765
|
+
if (!redis || !(await ensureRedisReady(redis))) {
|
|
766
|
+
return await allocateAndStoreSmartproxyPool(
|
|
767
|
+
cacheKey,
|
|
768
|
+
policy,
|
|
769
|
+
appKey,
|
|
770
|
+
lifetimeMinutes,
|
|
771
|
+
startedAt,
|
|
772
|
+
{ cacheStatus: "allocator" },
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const poolKey = smartproxyRedisPoolKey(cacheKey);
|
|
777
|
+
const lockKey = smartproxyRedisLockKey(cacheKey);
|
|
778
|
+
const waitStartedAt = Date.now();
|
|
779
|
+
|
|
780
|
+
while (Date.now() - waitStartedAt < SMARTPROXY_LOCK_POLL_MAX_MS) {
|
|
781
|
+
const token = randomUUID();
|
|
782
|
+
const acquired = await withRedisTimeout(() =>
|
|
783
|
+
redis.set(lockKey, token, "PX", SMARTPROXY_LOCK_TTL_MS, "NX"),
|
|
784
|
+
);
|
|
785
|
+
if (acquired === "OK") {
|
|
786
|
+
try {
|
|
787
|
+
return await allocateAndStoreSmartproxyPool(
|
|
788
|
+
cacheKey,
|
|
789
|
+
policy,
|
|
790
|
+
appKey,
|
|
791
|
+
lifetimeMinutes,
|
|
792
|
+
startedAt,
|
|
793
|
+
{
|
|
794
|
+
cacheStatus: options.background
|
|
795
|
+
? "soft_stale_refresh"
|
|
796
|
+
: "allocator",
|
|
797
|
+
redis,
|
|
798
|
+
poolKey,
|
|
799
|
+
},
|
|
800
|
+
);
|
|
801
|
+
} finally {
|
|
802
|
+
await releaseSmartproxyRedisLock(redis, lockKey, token);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const redisResult = await readSmartproxyRedisPool(cacheKey, startedAt);
|
|
807
|
+
if (redisResult) {
|
|
808
|
+
return {
|
|
809
|
+
pool: redisResult.pool,
|
|
810
|
+
telemetry: {
|
|
811
|
+
...redisResult.telemetry,
|
|
812
|
+
cacheStatus: "lock_wait",
|
|
813
|
+
cacheHit: true,
|
|
814
|
+
lockWaitMs: Math.max(0, Date.now() - waitStartedAt),
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const pttl = await withRedisTimeout(() => redis.pttl(lockKey));
|
|
820
|
+
if (typeof pttl === "number" && pttl <= 0) {
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (
|
|
824
|
+
Date.now() - startedAt >
|
|
825
|
+
SMARTPROXY_LOCK_POLL_MAX_MS - SMARTPROXY_DEADLINE_MARGIN_MS
|
|
826
|
+
) {
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
await sleep(
|
|
830
|
+
Math.min(500, Math.max(50, typeof pttl === "number" ? pttl : 100)),
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
throw new ProxyResolutionError(
|
|
835
|
+
"PROXY_ALLOCATION_FAILED",
|
|
836
|
+
"Smartproxy allocator lock did not produce a usable proxy pool before the request deadline.",
|
|
837
|
+
{
|
|
838
|
+
telemetry: telemetryForFailure("lock_wait", startedAt, {
|
|
839
|
+
lockWaitMs: Math.max(0, Date.now() - waitStartedAt),
|
|
840
|
+
}),
|
|
841
|
+
},
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function releaseSmartproxyRedisLock(
|
|
846
|
+
redis: ProxyRedisClient,
|
|
847
|
+
lockKey: string,
|
|
848
|
+
token: string,
|
|
849
|
+
): Promise<void> {
|
|
850
|
+
await withRedisTimeout(() =>
|
|
851
|
+
redis.eval(
|
|
852
|
+
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
|
|
853
|
+
1,
|
|
854
|
+
lockKey,
|
|
855
|
+
token,
|
|
856
|
+
),
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
861
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
862
|
+
return new Promise((resolve, reject) => {
|
|
863
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
864
|
+
const onAbort = () => {
|
|
865
|
+
clearTimeout(timeout);
|
|
866
|
+
signal?.removeEventListener("abort", onAbort);
|
|
867
|
+
reject(signal?.reason);
|
|
868
|
+
};
|
|
869
|
+
timeout = setTimeout(() => {
|
|
870
|
+
signal?.removeEventListener("abort", onAbort);
|
|
871
|
+
resolve();
|
|
872
|
+
}, ms);
|
|
873
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function smartproxyAllocatorDeadlineMs(): number {
|
|
878
|
+
return (
|
|
879
|
+
smartproxyAllocatorDeadlineMsForTests ?? SMARTPROXY_ALLOCATOR_DEADLINE_MS
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function createDeadlineAbortController(deadlineAt: number): {
|
|
884
|
+
controller: AbortController;
|
|
885
|
+
dispose: () => void;
|
|
886
|
+
} {
|
|
887
|
+
const controller = new AbortController();
|
|
888
|
+
const remainingMs = deadlineAt - Date.now();
|
|
889
|
+
if (remainingMs <= 0) {
|
|
890
|
+
controller.abort(new Error("Smartproxy allocator deadline exceeded."));
|
|
891
|
+
return { controller, dispose: () => undefined };
|
|
892
|
+
}
|
|
893
|
+
const timeout = setTimeout(() => {
|
|
894
|
+
controller.abort(new Error("Smartproxy allocator deadline exceeded."));
|
|
895
|
+
}, remainingMs);
|
|
896
|
+
return {
|
|
897
|
+
controller,
|
|
898
|
+
dispose: () => clearTimeout(timeout),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function smartproxyAllocatorDeadlineFailure(
|
|
903
|
+
attempt: number,
|
|
904
|
+
): SmartproxyAllocatorFailure {
|
|
905
|
+
return {
|
|
906
|
+
ok: false,
|
|
907
|
+
attempt,
|
|
908
|
+
bodyClass: "network_error",
|
|
909
|
+
cause: new Error("Smartproxy allocator deadline exceeded."),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function smartproxyAllocatorDeadlineError(): Error {
|
|
914
|
+
return new Error("Smartproxy allocator deadline exceeded.");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function readSmartproxyAllocatorBodyWithDeadline(
|
|
918
|
+
response: Response,
|
|
919
|
+
signal: AbortSignal,
|
|
920
|
+
): Promise<string> {
|
|
921
|
+
if (signal.aborted) {
|
|
922
|
+
throw signal.reason ?? smartproxyAllocatorDeadlineError();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return await new Promise<string>((resolve, reject) => {
|
|
926
|
+
let settled = false;
|
|
927
|
+
const cleanup = () => {
|
|
928
|
+
signal.removeEventListener("abort", onAbort);
|
|
929
|
+
};
|
|
930
|
+
const settle = (callback: () => void) => {
|
|
931
|
+
if (settled) return;
|
|
932
|
+
settled = true;
|
|
933
|
+
cleanup();
|
|
934
|
+
callback();
|
|
935
|
+
};
|
|
936
|
+
const onAbort = () => {
|
|
937
|
+
settle(() => reject(signal.reason ?? smartproxyAllocatorDeadlineError()));
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
941
|
+
|
|
942
|
+
try {
|
|
943
|
+
void response.text().then(
|
|
944
|
+
(body) => settle(() => resolve(body)),
|
|
945
|
+
(error: unknown) => settle(() => reject(error)),
|
|
946
|
+
);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
settle(() => reject(error));
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function allocateAndStoreSmartproxyPool(
|
|
954
|
+
cacheKey: string,
|
|
955
|
+
policy: ProviderProxyPolicy,
|
|
956
|
+
appKey: string,
|
|
957
|
+
lifetimeMinutes: number,
|
|
958
|
+
startedAt: number,
|
|
959
|
+
options: {
|
|
960
|
+
cacheStatus: ProxyCacheStatus;
|
|
961
|
+
redis?: ProxyRedisClient;
|
|
962
|
+
poolKey?: string;
|
|
963
|
+
},
|
|
964
|
+
): Promise<SmartproxyAllocationResult> {
|
|
965
|
+
const poolSize = resolveSmartproxyPoolSize(policy);
|
|
966
|
+
const allocatorUrl = buildSmartproxyAllocatorUrl(
|
|
967
|
+
policy,
|
|
968
|
+
appKey,
|
|
969
|
+
lifetimeMinutes,
|
|
970
|
+
poolSize,
|
|
971
|
+
);
|
|
972
|
+
const allocatorStartedAt = Date.now();
|
|
973
|
+
const allocatorDeadlineAt =
|
|
974
|
+
allocatorStartedAt + smartproxyAllocatorDeadlineMs();
|
|
975
|
+
let allocation: SmartproxyAllocatorSuccess | undefined;
|
|
976
|
+
let lastFailure: SmartproxyAllocatorFailure | undefined;
|
|
977
|
+
for (
|
|
978
|
+
let attempt = 1;
|
|
979
|
+
attempt <= SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS;
|
|
980
|
+
attempt += 1
|
|
981
|
+
) {
|
|
982
|
+
if (Date.now() >= allocatorDeadlineAt) {
|
|
983
|
+
lastFailure = smartproxyAllocatorDeadlineFailure(attempt);
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
const attemptResult = await fetchSmartproxyAllocatorAttempt(
|
|
987
|
+
allocatorUrl,
|
|
988
|
+
attempt,
|
|
989
|
+
allocatorDeadlineAt,
|
|
990
|
+
);
|
|
991
|
+
if (attemptResult.ok) {
|
|
992
|
+
allocation = attemptResult;
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
lastFailure = attemptResult;
|
|
996
|
+
if (attempt < SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS) {
|
|
997
|
+
if (Date.now() >= allocatorDeadlineAt) break;
|
|
998
|
+
const { controller, dispose } =
|
|
999
|
+
createDeadlineAbortController(allocatorDeadlineAt);
|
|
1000
|
+
try {
|
|
1001
|
+
await sleep(smartproxyAllocatorBackoffMs(attempt), controller.signal);
|
|
1002
|
+
} catch {
|
|
1003
|
+
break;
|
|
1004
|
+
} finally {
|
|
1005
|
+
dispose();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const allocatorMs = Math.max(0, Date.now() - allocatorStartedAt);
|
|
1011
|
+
const allocatorAttempts =
|
|
1012
|
+
allocation?.attempt ??
|
|
1013
|
+
lastFailure?.attempt ??
|
|
1014
|
+
SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS;
|
|
1015
|
+
const allocatorBodyClass =
|
|
1016
|
+
lastFailure?.bodyClass ?? allocation?.bodyClass ?? "usable_proxy_endpoints";
|
|
1017
|
+
const allocatorStatus = lastFailure ? lastFailure.status : allocation?.status;
|
|
1018
|
+
|
|
1019
|
+
if (!allocation) {
|
|
1020
|
+
throw new ProxyResolutionError(
|
|
1021
|
+
"PROXY_ALLOCATION_FAILED",
|
|
1022
|
+
smartproxyAllocatorFailureMessage(lastFailure),
|
|
1023
|
+
{
|
|
1024
|
+
cause: lastFailure?.cause,
|
|
1025
|
+
telemetry: telemetryForFailure(options.cacheStatus, startedAt, {
|
|
1026
|
+
allocatorMs,
|
|
1027
|
+
allocatorAttempts,
|
|
1028
|
+
allocatorBodyClass,
|
|
1029
|
+
...(allocatorStatus === undefined ? {} : { allocatorStatus }),
|
|
1030
|
+
attempts: allocatorAttempts,
|
|
1031
|
+
}),
|
|
1032
|
+
},
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const urls = allocation.urls;
|
|
1037
|
+
|
|
1038
|
+
const allocatedAt = Date.now();
|
|
1039
|
+
const ttlMs = SMARTPROXY_EXTRACTION_CACHE_TTL_MS;
|
|
1040
|
+
const refreshAfter = allocatedAt + SMARTPROXY_EXTRACTION_SOFT_REFRESH_MS;
|
|
1041
|
+
const result: CachedProxyPool = {
|
|
1042
|
+
urls,
|
|
1043
|
+
allocatedAt,
|
|
1044
|
+
refreshAfter,
|
|
1045
|
+
expiresAt: allocatedAt + ttlMs,
|
|
1046
|
+
diagnostics: {
|
|
1047
|
+
provider: "smartproxy",
|
|
1048
|
+
country: resolveSmartproxyCountry(policy) ?? "default",
|
|
1049
|
+
lifetimeMinutes,
|
|
1050
|
+
affinity: policy.session?.affinity ?? "request",
|
|
1051
|
+
rawConnect: true,
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
proxyCache.set(cacheKey, result);
|
|
1055
|
+
if (options.redis && options.poolKey) {
|
|
1056
|
+
const redis = options.redis;
|
|
1057
|
+
const poolKey = options.poolKey;
|
|
1058
|
+
const redisWriteStartedAt = Date.now();
|
|
1059
|
+
await withRedisTimeout(() =>
|
|
1060
|
+
redis.set(
|
|
1061
|
+
poolKey,
|
|
1062
|
+
serializeSmartproxyPool(result),
|
|
1063
|
+
"PX",
|
|
1064
|
+
Math.max(1_000, result.expiresAt - Date.now()),
|
|
1065
|
+
),
|
|
1066
|
+
);
|
|
1067
|
+
return {
|
|
1068
|
+
pool: result,
|
|
1069
|
+
telemetry: telemetryForPool(result, options.cacheStatus, startedAt, {
|
|
1070
|
+
allocatorMs,
|
|
1071
|
+
allocatorAttempts,
|
|
1072
|
+
allocatorBodyClass,
|
|
1073
|
+
...(allocatorStatus === undefined ? {} : { allocatorStatus }),
|
|
1074
|
+
attempts: allocatorAttempts,
|
|
1075
|
+
redisWriteMs: Math.max(0, Date.now() - redisWriteStartedAt),
|
|
1076
|
+
}),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
pool: result,
|
|
1081
|
+
telemetry: telemetryForPool(result, options.cacheStatus, startedAt, {
|
|
1082
|
+
allocatorMs,
|
|
1083
|
+
allocatorAttempts,
|
|
1084
|
+
allocatorBodyClass,
|
|
1085
|
+
...(allocatorStatus === undefined ? {} : { allocatorStatus }),
|
|
1086
|
+
attempts: allocatorAttempts,
|
|
1087
|
+
}),
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
type SmartproxyAllocatorAttemptResult =
|
|
1092
|
+
| SmartproxyAllocatorSuccess
|
|
1093
|
+
| SmartproxyAllocatorFailure;
|
|
1094
|
+
|
|
1095
|
+
type SmartproxyAllocatorSuccess = {
|
|
1096
|
+
ok: true;
|
|
1097
|
+
attempt: number;
|
|
1098
|
+
status: number;
|
|
1099
|
+
bodyClass: SmartproxyAllocatorBodyClass;
|
|
1100
|
+
urls: string[];
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
type SmartproxyAllocatorFailure = {
|
|
1104
|
+
ok: false;
|
|
1105
|
+
attempt: number;
|
|
1106
|
+
status?: number;
|
|
1107
|
+
bodyClass: SmartproxyAllocatorBodyClass;
|
|
1108
|
+
cause?: unknown;
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
async function fetchSmartproxyAllocatorAttempt(
|
|
1112
|
+
allocatorUrl: string,
|
|
1113
|
+
attempt: number,
|
|
1114
|
+
deadlineAt: number,
|
|
1115
|
+
): Promise<SmartproxyAllocatorAttemptResult> {
|
|
1116
|
+
const { controller, dispose } = createDeadlineAbortController(deadlineAt);
|
|
1117
|
+
let response: Response;
|
|
1118
|
+
try {
|
|
1119
|
+
response = await fetch(allocatorUrl, {
|
|
1120
|
+
headers: { Accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
|
|
1121
|
+
signal: controller.signal,
|
|
1122
|
+
});
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
dispose();
|
|
1125
|
+
return {
|
|
1126
|
+
ok: false,
|
|
1127
|
+
attempt,
|
|
1128
|
+
bodyClass: "network_error",
|
|
1129
|
+
cause: error,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
let body: string;
|
|
1134
|
+
try {
|
|
1135
|
+
body = await readSmartproxyAllocatorBodyWithDeadline(
|
|
1136
|
+
response,
|
|
1137
|
+
controller.signal,
|
|
1138
|
+
);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
dispose();
|
|
1141
|
+
return {
|
|
1142
|
+
ok: false,
|
|
1143
|
+
attempt,
|
|
1144
|
+
status: response.status,
|
|
1145
|
+
bodyClass: "network_error",
|
|
1146
|
+
cause: error,
|
|
1147
|
+
};
|
|
1148
|
+
} finally {
|
|
1149
|
+
dispose();
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (!response.ok) {
|
|
1153
|
+
return {
|
|
1154
|
+
ok: false,
|
|
1155
|
+
attempt,
|
|
1156
|
+
status: response.status,
|
|
1157
|
+
bodyClass: "http_error",
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const urls = parseSmartproxyAllocatorProxies(body);
|
|
1162
|
+
const bodyClass = classifySmartproxyAllocatorBody(body, urls);
|
|
1163
|
+
if (urls.length === 0) {
|
|
1164
|
+
return {
|
|
1165
|
+
ok: false,
|
|
1166
|
+
attempt,
|
|
1167
|
+
status: response.status,
|
|
1168
|
+
bodyClass,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
ok: true,
|
|
1173
|
+
attempt,
|
|
1174
|
+
status: response.status,
|
|
1175
|
+
bodyClass,
|
|
1176
|
+
urls,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function smartproxyAllocatorBackoffMs(attempt: number): number {
|
|
1181
|
+
const base =
|
|
1182
|
+
SMARTPROXY_ALLOCATOR_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1);
|
|
1183
|
+
const jitter = Math.floor(Math.random() * SMARTPROXY_ALLOCATOR_RETRY_BASE_MS);
|
|
1184
|
+
return base + jitter;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function smartproxyAllocatorFailureMessage(
|
|
1188
|
+
failure: SmartproxyAllocatorFailure | undefined,
|
|
1189
|
+
): string {
|
|
1190
|
+
if (!failure) {
|
|
1191
|
+
return "Smartproxy allocator failed.";
|
|
1192
|
+
}
|
|
1193
|
+
if (failure.bodyClass === "network_error") {
|
|
1194
|
+
return "Smartproxy allocator request failed.";
|
|
1195
|
+
}
|
|
1196
|
+
if (failure.bodyClass === "http_error") {
|
|
1197
|
+
return `Smartproxy allocator returned HTTP ${failure.status ?? "error"}.`;
|
|
1198
|
+
}
|
|
1199
|
+
return "Smartproxy allocator response did not contain a usable proxy endpoint.";
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function buildSmartproxyAllocatorUrl(
|
|
1203
|
+
policy: ProviderProxyPolicy,
|
|
1204
|
+
appKey: string,
|
|
1205
|
+
lifetimeMinutes: number,
|
|
1206
|
+
poolSize: number,
|
|
1207
|
+
): string {
|
|
1208
|
+
const params = new URLSearchParams({
|
|
1209
|
+
app_key: appKey,
|
|
1210
|
+
pt: "9",
|
|
1211
|
+
num: String(poolSize),
|
|
1212
|
+
life: String(lifetimeMinutes),
|
|
1213
|
+
protocol: "1",
|
|
1214
|
+
format: "txt",
|
|
1215
|
+
lb: "\\n",
|
|
1216
|
+
});
|
|
1217
|
+
const country = resolveSmartproxyCountry(policy);
|
|
1218
|
+
if (country) {
|
|
1219
|
+
params.set("cc", country);
|
|
1220
|
+
}
|
|
1221
|
+
return `https://www.smartproxy.org/web_v1/ip/get-ip-v3?${params.toString()}`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function parseSmartproxyAllocatorProxies(body: string): string[] {
|
|
1225
|
+
const trimmed = body.trim();
|
|
1226
|
+
if (!trimmed) {
|
|
1227
|
+
return [];
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
try {
|
|
1231
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
1232
|
+
const data =
|
|
1233
|
+
parsed && typeof parsed === "object" && "data" in parsed
|
|
1234
|
+
? parsed.data
|
|
1235
|
+
: undefined;
|
|
1236
|
+
const list =
|
|
1237
|
+
data && typeof data === "object" && "list" in data
|
|
1238
|
+
? data.list
|
|
1239
|
+
: undefined;
|
|
1240
|
+
if (Array.isArray(list)) {
|
|
1241
|
+
return list
|
|
1242
|
+
.map((item) => {
|
|
1243
|
+
if (!item || typeof item !== "object") {
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
const ip = "ip" in item && typeof item.ip === "string" ? item.ip : "";
|
|
1247
|
+
const port =
|
|
1248
|
+
"port" in item &&
|
|
1249
|
+
(typeof item.port === "string" || typeof item.port === "number")
|
|
1250
|
+
? item.port
|
|
1251
|
+
: "";
|
|
1252
|
+
return ip && port ? `http://${ip}:${port}` : null;
|
|
1253
|
+
})
|
|
1254
|
+
.filter((url): url is string => url !== null);
|
|
1255
|
+
}
|
|
1256
|
+
} catch {
|
|
1257
|
+
// Text allocator format falls through.
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return trimmed
|
|
1261
|
+
.split(/\r?\n/)
|
|
1262
|
+
.map((item) => item.trim())
|
|
1263
|
+
.filter((item) => /^\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}$/.test(item))
|
|
1264
|
+
.map((line) => `http://${line}`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function classifySmartproxyAllocatorBody(
|
|
1268
|
+
body: string,
|
|
1269
|
+
urls: string[],
|
|
1270
|
+
): SmartproxyAllocatorBodyClass {
|
|
1271
|
+
if (urls.length > 0) {
|
|
1272
|
+
return "usable_proxy_endpoints";
|
|
1273
|
+
}
|
|
1274
|
+
const trimmed = body.trim();
|
|
1275
|
+
if (!trimmed) {
|
|
1276
|
+
return "empty";
|
|
1277
|
+
}
|
|
1278
|
+
try {
|
|
1279
|
+
JSON.parse(trimmed);
|
|
1280
|
+
return "json_without_proxies";
|
|
1281
|
+
} catch {
|
|
1282
|
+
return "text_without_proxies";
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
export function clearProxyResolutionCache(): void {
|
|
1287
|
+
proxyCache.clear();
|
|
1288
|
+
proxyInflight.clear();
|
|
1289
|
+
invalidatedProxyKeys.clear();
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function markSmartproxyCacheInvalidated(
|
|
1293
|
+
options: ProxyResolutionOptions = {},
|
|
1294
|
+
): string | undefined {
|
|
1295
|
+
const policy = resolvePolicy(options);
|
|
1296
|
+
if (!policy || policy.mode === "disabled") {
|
|
1297
|
+
return undefined;
|
|
1298
|
+
}
|
|
1299
|
+
if (resolveProxyProvider(policy) !== "smartproxy") {
|
|
1300
|
+
return undefined;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const lifetimeMinutes = resolveSmartproxyLifetime(policy);
|
|
1304
|
+
const cacheKey = buildSmartproxyCacheKey(
|
|
1305
|
+
policy,
|
|
1306
|
+
options.affinityKey,
|
|
1307
|
+
lifetimeMinutes,
|
|
1308
|
+
);
|
|
1309
|
+
invalidatedProxyKeys.set(
|
|
1310
|
+
cacheKey,
|
|
1311
|
+
Date.now() + SMARTPROXY_INVALIDATION_SKIP_REDIS_MS,
|
|
1312
|
+
);
|
|
1313
|
+
proxyCache.delete(cacheKey);
|
|
1314
|
+
proxyInflight.delete(cacheKey);
|
|
1315
|
+
return cacheKey;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
export function invalidateProxyResolutionCache(
|
|
1319
|
+
options: ProxyResolutionOptions = {},
|
|
1320
|
+
): boolean {
|
|
1321
|
+
return markSmartproxyCacheInvalidated(options) !== undefined;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
export async function invalidateProxyResolutionCacheAsync(
|
|
1325
|
+
options: ProxyResolutionOptions = {},
|
|
1326
|
+
): Promise<boolean> {
|
|
1327
|
+
const cacheKey = markSmartproxyCacheInvalidated(options);
|
|
1328
|
+
if (!cacheKey) return false;
|
|
1329
|
+
const redis = getProxyRedis();
|
|
1330
|
+
try {
|
|
1331
|
+
if (redis && (await ensureRedisReady(redis))) {
|
|
1332
|
+
await withRedisTimeout(() => redis.del(smartproxyRedisPoolKey(cacheKey)));
|
|
1333
|
+
}
|
|
1334
|
+
} catch {
|
|
1335
|
+
// Cache invalidation must not turn a stale proxy retry into a Redis outage.
|
|
1336
|
+
}
|
|
1337
|
+
return true;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
143
1340
|
export function defineConfig(config: ApiFuseConfig): ApiFuseConfig {
|
|
144
1341
|
return config;
|
|
145
1342
|
}
|