@apifuse/provider-sdk 2.2.0-beta.7 → 2.2.0-beta.9
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/CHANGELOG.md +8 -0
- package/dist/auth-turn/index.d.ts +2 -2
- package/dist/config/loader.d.ts +147 -6
- package/dist/config/loader.js +360 -48
- package/dist/define.js +63 -8
- package/dist/runtime/choice.js +20 -2
- package/dist/runtime/http.js +115 -9
- package/dist/runtime/proxy-errors.js +6 -2
- package/dist/runtime/proxy-nodemaven.d.ts +35 -0
- package/dist/runtime/proxy-nodemaven.js +128 -0
- package/dist/runtime/proxy-telemetry.d.ts +2 -1
- package/dist/runtime/proxy-telemetry.js +39 -4
- package/dist/runtime/state.js +12 -1
- package/dist/runtime/stealth.js +60 -18
- package/dist/server/serve.js +11 -0
- package/dist/server/types.d.ts +9 -9
- package/dist/types.d.ts +30 -1
- package/package.json +4 -3
- package/src/config/loader.ts +514 -61
- package/src/define.ts +76 -13
- package/src/runtime/choice.ts +25 -4
- package/src/runtime/http.ts +134 -11
- package/src/runtime/proxy-errors.ts +12 -4
- package/src/runtime/proxy-nodemaven.ts +178 -0
- package/src/runtime/proxy-telemetry.ts +56 -5
- package/src/runtime/state.ts +11 -1
- package/src/runtime/stealth.ts +68 -22
- package/src/server/serve.ts +11 -0
- package/src/types.ts +30 -1
package/src/define.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
ProviderDeploymentOverrides,
|
|
26
26
|
ProviderHealthMonitorConfig,
|
|
27
27
|
ProviderProxyConfig,
|
|
28
|
+
ProviderProxyProvider,
|
|
28
29
|
ProviderPublicProfile,
|
|
29
30
|
ProviderReviewed,
|
|
30
31
|
ProviderSecretDeclaration,
|
|
@@ -78,7 +79,7 @@ const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
|
|
|
78
79
|
const VALID_AUTH_MODES = ["none", "platform-managed", "credentials", "oauth2"] as const;
|
|
79
80
|
const VALID_PROVIDER_ACCESS_VISIBILITIES = ["public", "early_access"] as const;
|
|
80
81
|
const VALID_PROVIDER_PROXY_MODES = ["disabled", "optional", "required"] as const;
|
|
81
|
-
const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "decodo", "custom"] as const;
|
|
82
|
+
const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "nodemaven", "decodo", "custom"] as const;
|
|
82
83
|
const VALID_PROVIDER_PROXY_AFFINITIES = [
|
|
83
84
|
"request",
|
|
84
85
|
"operation",
|
|
@@ -87,6 +88,20 @@ const VALID_PROVIDER_PROXY_AFFINITIES = [
|
|
|
87
88
|
] as const;
|
|
88
89
|
const VALID_PROVIDER_STT_MODES = ["optional", "required"] as const;
|
|
89
90
|
const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
|
|
91
|
+
const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
|
|
92
|
+
const NODEMAVEN_PASSWORD_SECRET = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
|
|
93
|
+
// Per-vendor provider-declared credential secrets. A required-mode chain must
|
|
94
|
+
// declare every secret of every credentialed vendor it names, so a missing
|
|
95
|
+
// credential fails at build/validation time rather than during a live outage: a
|
|
96
|
+
// declared-but-uncredentialed fallback leg is a silently dead SPOF, which is
|
|
97
|
+
// exactly the failure class the multi-vendor chain exists to remove. Vendors
|
|
98
|
+
// absent from this map (e.g. `custom`/`decodo`, whose credentials come from the
|
|
99
|
+
// `APIFUSE__PROXY__URL` bring-your-own escape hatch, not provider secrets) impose
|
|
100
|
+
// no declaration requirement.
|
|
101
|
+
const VENDOR_REQUIRED_SECRETS: Partial<Record<ProviderProxyProvider, readonly string[]>> = {
|
|
102
|
+
smartproxy: [SMARTPROXY_APP_KEY_SECRET],
|
|
103
|
+
nodemaven: [NODEMAVEN_USERNAME_SECRET, NODEMAVEN_PASSWORD_SECRET],
|
|
104
|
+
};
|
|
90
105
|
const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
|
|
91
106
|
const MCP_TOOL_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
|
|
92
107
|
const VALID_OPERATION_RISK_CLASSES = ["read", "write", "destructive", "external-send"] as const;
|
|
@@ -371,11 +386,24 @@ function validateProviderProxy(config: {
|
|
|
371
386
|
},
|
|
372
387
|
);
|
|
373
388
|
}
|
|
374
|
-
rejectUnknownFields(proxy, new Set(["mode", "provider", "geo", "session"]), "proxy");
|
|
389
|
+
rejectUnknownFields(proxy, new Set(["mode", "provider", "providers", "geo", "session"]), "proxy");
|
|
375
390
|
assertLiteralField(proxy.mode, "proxy.mode", VALID_PROVIDER_PROXY_MODES, config.id);
|
|
376
391
|
if (proxy.provider !== undefined) {
|
|
377
392
|
assertLiteralField(proxy.provider, "proxy.provider", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
|
|
378
393
|
}
|
|
394
|
+
if (proxy.providers !== undefined) {
|
|
395
|
+
if (!Array.isArray(proxy.providers) || proxy.providers.length === 0) {
|
|
396
|
+
throw new ValidationError(
|
|
397
|
+
`Provider "${config.id}" has invalid proxy.providers: must be a non-empty array of proxy vendors.`,
|
|
398
|
+
{
|
|
399
|
+
fix: `Use proxy.providers: ["smartproxy", "nodemaven"] to declare an ordered fallback chain.`,
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
for (const vendor of proxy.providers) {
|
|
404
|
+
assertLiteralField(vendor, "proxy.providers[]", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
379
407
|
if (proxy.geo !== undefined) {
|
|
380
408
|
if (!proxy.geo || typeof proxy.geo !== "object" || Array.isArray(proxy.geo)) {
|
|
381
409
|
throw new ValidationError(
|
|
@@ -433,19 +461,54 @@ function validateProviderProxy(config: {
|
|
|
433
461
|
);
|
|
434
462
|
}
|
|
435
463
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
464
|
+
// Every credentialed vendor in a required-mode chain must declare its
|
|
465
|
+
// provider secret(s) so a missing credential fails at build/validation time,
|
|
466
|
+
// not during a live outage. This covers the fallback legs too (not just the
|
|
467
|
+
// first vendor): a declared-but-uncredentialed nodemaven fallback would leave
|
|
468
|
+
// the chain silently down to a single vendor, reintroducing the SPOF the chain
|
|
469
|
+
// removes.
|
|
470
|
+
const vendorChain =
|
|
471
|
+
proxy.providers && proxy.providers.length > 0
|
|
472
|
+
? proxy.providers
|
|
473
|
+
: proxy.provider
|
|
474
|
+
? [proxy.provider]
|
|
475
|
+
: [];
|
|
476
|
+
if (proxy.mode === "required") {
|
|
477
|
+
for (const vendor of vendorChain) {
|
|
478
|
+
const requiredSecrets = VENDOR_REQUIRED_SECRETS[vendor];
|
|
479
|
+
if (!requiredSecrets) continue;
|
|
480
|
+
for (const secretName of requiredSecrets) {
|
|
481
|
+
// Match the canonical runtime gate (assertRequiredSecretsPresent /
|
|
482
|
+
// listMissingRequiredSecrets), which enforces only `required === true`
|
|
483
|
+
// declarations. A declaration that omits `required` (defaulting to
|
|
484
|
+
// optional) is skipped at runtime, so accepting it here would pass
|
|
485
|
+
// validation while leaving the credential unenforced until proxy
|
|
486
|
+
// resolution during a live request — the fail-open gap this check exists
|
|
487
|
+
// to close.
|
|
488
|
+
const declared = config.secrets?.some(
|
|
489
|
+
(secret) => secret.name === secretName && secret.required === true,
|
|
490
|
+
);
|
|
491
|
+
if (!declared) {
|
|
492
|
+
throw new ValidationError(
|
|
493
|
+
`Provider "${config.id}" requires ${vendor} egress but does not declare ${secretName}.`,
|
|
494
|
+
{
|
|
495
|
+
fix: `Add secrets: [{ name: "${secretName}", required: true }] to the provider (every vendor in a required proxy chain must declare its credential secrets).`,
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
447
500
|
}
|
|
448
501
|
}
|
|
502
|
+
// `decodo`/`custom` are deprecated vendor values (string-union members, so the
|
|
503
|
+
// @deprecated symbol gate can't catch them — warn at validation time instead).
|
|
504
|
+
const deprecatedVendors = vendorChain.filter(
|
|
505
|
+
(vendor) => vendor === "decodo" || vendor === "custom",
|
|
506
|
+
);
|
|
507
|
+
if (deprecatedVendors.length > 0) {
|
|
508
|
+
console.warn(
|
|
509
|
+
`[provider-sdk] Provider "${config.id}" uses deprecated proxy vendor(s): ${deprecatedVendors.join(", ")}. Use "smartproxy"/"nodemaven", or the APIFUSE__PROXY__URL bring-your-own escape hatch.`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
449
512
|
}
|
|
450
513
|
|
|
451
514
|
function validateProviderStt(config: { id: string; stt?: ProviderSttConfig }): void {
|
package/src/runtime/choice.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
ProviderChoiceTokenError,
|
|
12
12
|
type ProviderChoiceTokenPayload,
|
|
13
13
|
} from "../choice-token.js";
|
|
14
|
-
import { ProviderError } from "../errors.js";
|
|
14
|
+
import { isProviderError, ProviderError } from "../errors.js";
|
|
15
15
|
import type {
|
|
16
16
|
CredentialContext,
|
|
17
17
|
EnvContext,
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
ProviderRequestContext,
|
|
24
24
|
ProviderRuntimeState,
|
|
25
25
|
ProviderStateDurationString,
|
|
26
|
+
StateValue,
|
|
26
27
|
} from "../types.js";
|
|
27
28
|
|
|
28
29
|
export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV =
|
|
@@ -359,9 +360,29 @@ async function parseServerStoredChoice(options: {
|
|
|
359
360
|
storage,
|
|
360
361
|
contextState: options.contextState,
|
|
361
362
|
});
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)
|
|
363
|
+
// Reading a server-stored choice back deserializes a persisted value. A
|
|
364
|
+
// corrupt/undecodable value would otherwise surface as a raw JSON.parse
|
|
365
|
+
// SyntaxError (or another unexpected throwable) that escapes the choice error
|
|
366
|
+
// taxonomy, gets masked as internal_error 500, and is treated as retryable by
|
|
367
|
+
// the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
|
|
368
|
+
// Convert any non-branded throwable into a branded invalid_payload so it maps
|
|
369
|
+
// to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
|
|
370
|
+
// ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
|
|
371
|
+
// their category/retryable semantics are preserved.
|
|
372
|
+
let record: StateValue<ProviderChoiceTokenPayload> | null;
|
|
373
|
+
try {
|
|
374
|
+
record = await namespace.get<ProviderChoiceTokenPayload>(
|
|
375
|
+
optionsStateKey(options.handle.state_id),
|
|
376
|
+
);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
throw new ProviderChoiceTokenError(
|
|
382
|
+
"invalid_payload",
|
|
383
|
+
"Provider choice token state payload could not be decoded.",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
365
386
|
if (!record) {
|
|
366
387
|
throw new ProviderChoiceTokenError(
|
|
367
388
|
"invalid_payload",
|
package/src/runtime/http.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { ProxyResolutionOptions } from "../config/loader.js";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
policyRotatesTransportVendorChain,
|
|
4
|
+
resolvePolicyTransportAttemptCap,
|
|
5
|
+
resolveProxyConfigAsync,
|
|
6
|
+
} from "../config/loader.js";
|
|
3
7
|
import { ProviderError, TransportError } from "../errors.js";
|
|
4
8
|
import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
|
|
5
9
|
import type {
|
|
@@ -8,6 +12,7 @@ import type {
|
|
|
8
12
|
HttpResponse,
|
|
9
13
|
HttpRetrySummary,
|
|
10
14
|
HttpStreamResponse,
|
|
15
|
+
ProviderProxyPolicy,
|
|
11
16
|
RequestOptions,
|
|
12
17
|
RequestWithMethodOptions,
|
|
13
18
|
SseMessage,
|
|
@@ -41,16 +46,28 @@ type HttpStatusOutcome = {
|
|
|
41
46
|
proxyUsed: boolean;
|
|
42
47
|
};
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Sentinel returned when a policy-allocator attempt resolved an endpoint that a
|
|
51
|
+
* prior attempt already tried (an under-filled pool repeats endpoints via the
|
|
52
|
+
* modulo mapping before the flat offset crosses into the next vendor). The retry
|
|
53
|
+
* loop advances to the next offset rather than re-issuing the request — but does
|
|
54
|
+
* NOT treat it as chain exhaustion, so the offset still walks into the fallback
|
|
55
|
+
* vendor's span.
|
|
56
|
+
*/
|
|
57
|
+
type NativeHttpSkipOutcome = { kind: "dedupe-skip" };
|
|
58
|
+
|
|
59
|
+
type NativeHttpAttemptOutcome = HttpResponse | HttpStatusOutcome | NativeHttpSkipOutcome;
|
|
45
60
|
|
|
46
61
|
type NativeHttpAttemptError = TransportError & { proxyUsed?: boolean };
|
|
47
62
|
|
|
48
|
-
function isHttpStatusOutcome(
|
|
49
|
-
outcome: HttpResponse | HttpStatusOutcome,
|
|
50
|
-
): outcome is HttpStatusOutcome {
|
|
63
|
+
function isHttpStatusOutcome(outcome: NativeHttpAttemptOutcome): outcome is HttpStatusOutcome {
|
|
51
64
|
return "kind" in outcome && outcome.kind === "http-status";
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
function isDedupeSkipOutcome(outcome: NativeHttpAttemptOutcome): outcome is NativeHttpSkipOutcome {
|
|
68
|
+
return "kind" in outcome && outcome.kind === "dedupe-skip";
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
async function sleep(ms: number): Promise<void> {
|
|
55
72
|
if (ms <= 0) return;
|
|
56
73
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -256,6 +273,9 @@ async function resolveNativeProxy(
|
|
|
256
273
|
baseProxyAttempt: clientOptions.proxyAttempt,
|
|
257
274
|
retryAttemptOffset: proxyAttemptOffset,
|
|
258
275
|
}),
|
|
276
|
+
// Bun's native fetch proxy option tunnels HTTP CONNECT only; SOCKS5 is not
|
|
277
|
+
// supported here, so a socks5 policy fails loudly rather than downgrading.
|
|
278
|
+
transportProtocols: ["http"],
|
|
259
279
|
telemetry: clientOptions.telemetry,
|
|
260
280
|
});
|
|
261
281
|
if (resolvedProxy.shouldWarn) {
|
|
@@ -294,6 +314,7 @@ async function fetchNativeHttp(
|
|
|
294
314
|
warn: (message: string) => void,
|
|
295
315
|
statusRetryCodes?: readonly number[],
|
|
296
316
|
proxyAttemptOffset = 0,
|
|
317
|
+
dedupe?: { attempted: Set<string> },
|
|
297
318
|
): Promise<NativeHttpAttemptOutcome> {
|
|
298
319
|
const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
|
|
299
320
|
const controller = options.timeout ? new AbortController() : undefined;
|
|
@@ -303,7 +324,20 @@ async function fetchNativeHttp(
|
|
|
303
324
|
|
|
304
325
|
let proxy: string | undefined;
|
|
305
326
|
try {
|
|
327
|
+
// Resolve inside the try (and after the timeout is armed) so allocator
|
|
328
|
+
// failures are branded as TransportErrors and count against the request
|
|
329
|
+
// deadline, exactly as an inline resolve would.
|
|
306
330
|
proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
|
|
331
|
+
// For a registry allocator chain, skip an endpoint a prior attempt already
|
|
332
|
+
// tried rather than re-issuing the same request. Returning the sentinel
|
|
333
|
+
// (instead of breaking) lets the loop keep advancing the flat offset until
|
|
334
|
+
// it crosses into the fallback vendor's pool span.
|
|
335
|
+
if (dedupe && proxy) {
|
|
336
|
+
if (dedupe.attempted.has(proxy)) {
|
|
337
|
+
return { kind: "dedupe-skip" };
|
|
338
|
+
}
|
|
339
|
+
dedupe.attempted.add(proxy);
|
|
340
|
+
}
|
|
307
341
|
const requestInit: NativeFetchInit = {
|
|
308
342
|
headers: options.headers,
|
|
309
343
|
method,
|
|
@@ -449,6 +483,58 @@ export function createHttpClient(
|
|
|
449
483
|
? { ...headersOptions, throwOnHttpError: false }
|
|
450
484
|
: headersOptions;
|
|
451
485
|
|
|
486
|
+
// Span the whole vendor chain on transport failures. Like ctx.stealth, a
|
|
487
|
+
// policy-managed proxy resolves a *different* endpoint/vendor per attempt
|
|
488
|
+
// (the flat proxyAttemptOffset rotates across the concatenated vendor pool
|
|
489
|
+
// spans), so a transport failure should advance to the next endpoint —
|
|
490
|
+
// potentially crossing into the fallback vendor — rather than stopping at
|
|
491
|
+
// the per-endpoint retry budget and stranding the request on the primary
|
|
492
|
+
// vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
|
|
493
|
+
// only for implicit, safe-method allocator requests; explicit retry
|
|
494
|
+
// policies (their documented `attempts` ceiling), unsafe methods, and
|
|
495
|
+
// static/non-registry vendors keep the retry budget. Status-code retries
|
|
496
|
+
// stay bounded by the retry budget regardless; only transport rotation gets
|
|
497
|
+
// the full span.
|
|
498
|
+
const policyProxy: ProviderProxyPolicy | undefined = (() => {
|
|
499
|
+
const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
|
|
500
|
+
return policy && typeof policy === "object" ? policy : undefined;
|
|
501
|
+
})();
|
|
502
|
+
const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
|
|
503
|
+
const transportAttemptCap = retryOptions
|
|
504
|
+
? resolvePolicyTransportAttemptCap({
|
|
505
|
+
policy: policyProxy,
|
|
506
|
+
usesPolicyAllocator,
|
|
507
|
+
retryAttempts: retryOptions.attempts,
|
|
508
|
+
explicitRetry,
|
|
509
|
+
method: methodName,
|
|
510
|
+
})
|
|
511
|
+
: 1;
|
|
512
|
+
|
|
513
|
+
// Track resolved endpoints across a policy-allocator chain. Successive
|
|
514
|
+
// attempts rotate the flat offset across the concatenated vendor pool
|
|
515
|
+
// spans, but an under-filled allocation (fewer live endpoints than the
|
|
516
|
+
// configured pool size) makes the modulo mapping repeat endpoints before
|
|
517
|
+
// the offset reaches the next vendor. Rather than re-hammering an
|
|
518
|
+
// already-tried endpoint under backoff, fetchNativeHttp returns a skip
|
|
519
|
+
// sentinel for a duplicate; the loop then advances the flat offset without
|
|
520
|
+
// issuing the request, so it keeps walking toward — and into — the fallback
|
|
521
|
+
// vendor's pool span instead of stalling on the primary vendor.
|
|
522
|
+
// De-duplication is gated on the SAME predicate that widens the attempt cap
|
|
523
|
+
// (implicit, safe-method, registry-chain rotation). It must NOT engage for
|
|
524
|
+
// an explicit retry policy: there the caller's `attempts` count is the
|
|
525
|
+
// contract and each attempt must issue against whatever endpoint it resolves
|
|
526
|
+
// — even a repeat — instead of being silently skipped (which would collapse
|
|
527
|
+
// a `poolSize: 1` + `attempts: 3` request to a single fetch).
|
|
528
|
+
const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
|
|
529
|
+
policy: policyProxy,
|
|
530
|
+
usesPolicyAllocator,
|
|
531
|
+
explicitRetry,
|
|
532
|
+
method: methodName,
|
|
533
|
+
});
|
|
534
|
+
const dedupeContext = dedupeAllocatorEndpoints
|
|
535
|
+
? { attempted: new Set<string>() }
|
|
536
|
+
: undefined;
|
|
537
|
+
|
|
452
538
|
const executeOnce = (proxyAttemptOffset = 0): Promise<NativeHttpAttemptOutcome> =>
|
|
453
539
|
fetchNativeHttp(
|
|
454
540
|
baseUrl,
|
|
@@ -459,24 +545,52 @@ export function createHttpClient(
|
|
|
459
545
|
warnOnce,
|
|
460
546
|
statusRetryEnabled ? retryOptions?.statusCodes : undefined,
|
|
461
547
|
proxyAttemptOffset,
|
|
548
|
+
dedupeContext,
|
|
462
549
|
);
|
|
463
550
|
|
|
464
551
|
if (!retryEnabled || !retryOptions) {
|
|
465
552
|
const outcome = await executeOnce();
|
|
553
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
554
|
+
// Single-shot path never de-duplicates (dedupeContext is undefined),
|
|
555
|
+
// but keep the union total.
|
|
556
|
+
throw new TransportError("HTTP request produced no terminal result", {
|
|
557
|
+
code: "retry_exhausted",
|
|
558
|
+
});
|
|
559
|
+
}
|
|
466
560
|
if (isHttpStatusOutcome(outcome)) {
|
|
467
561
|
throw toUpstreamHttpError(outcome.status);
|
|
468
562
|
}
|
|
469
563
|
return outcome;
|
|
470
564
|
}
|
|
471
565
|
|
|
566
|
+
let lastError: unknown;
|
|
472
567
|
let lastErrorCode: string | undefined;
|
|
473
568
|
let lastStatus: number | undefined;
|
|
474
|
-
|
|
569
|
+
// `attempt` walks the flat proxy offset across the full chain span; `issued`
|
|
570
|
+
// counts requests that were actually sent (skipped duplicate offsets do not
|
|
571
|
+
// increment it). Retry summaries and the status-retry budget must reflect
|
|
572
|
+
// issued requests, not the raw offset, so they stay accurate when partial
|
|
573
|
+
// allocations skip offsets.
|
|
574
|
+
let issued = 0;
|
|
575
|
+
for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
|
|
576
|
+
// Whether this offset actually issued a request (vs. a skipped duplicate),
|
|
577
|
+
// so the catch counts a thrown *transport* failure once without
|
|
578
|
+
// double-counting a status outcome that already incremented before it
|
|
579
|
+
// re-threw as an upstream HTTP error.
|
|
580
|
+
let issuedThisAttempt = false;
|
|
475
581
|
try {
|
|
476
582
|
const outcome = await executeOnce(attempt - 1);
|
|
583
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
584
|
+
// Duplicate endpoint from a partial allocation: advance the flat
|
|
585
|
+
// offset without issuing the request (no backoff, not a failure) so
|
|
586
|
+
// the loop keeps rotating toward the fallback vendor.
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
issued += 1;
|
|
590
|
+
issuedThisAttempt = true;
|
|
477
591
|
if (isHttpStatusOutcome(outcome)) {
|
|
478
592
|
lastStatus = outcome.status;
|
|
479
|
-
if (outcome.retryable &&
|
|
593
|
+
if (outcome.retryable && issued < retryOptions.attempts) {
|
|
480
594
|
await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
|
|
481
595
|
continue;
|
|
482
596
|
}
|
|
@@ -488,10 +602,10 @@ export function createHttpClient(
|
|
|
488
602
|
throw toUpstreamHttpError(response.status);
|
|
489
603
|
}
|
|
490
604
|
|
|
491
|
-
if (
|
|
605
|
+
if (issued > 1) {
|
|
492
606
|
const summary: HttpRetrySummary = {
|
|
493
|
-
attempts:
|
|
494
|
-
retries:
|
|
607
|
+
attempts: issued,
|
|
608
|
+
retries: issued - 1,
|
|
495
609
|
...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
|
|
496
610
|
transport: "native",
|
|
497
611
|
...(lastErrorCode ? { lastErrorCode } : {}),
|
|
@@ -501,11 +615,13 @@ export function createHttpClient(
|
|
|
501
615
|
}
|
|
502
616
|
return response;
|
|
503
617
|
} catch (error) {
|
|
618
|
+
if (!issuedThisAttempt) issued += 1;
|
|
619
|
+
lastError = error;
|
|
504
620
|
lastErrorCode = proxyTransportRetryErrorCode(error);
|
|
505
621
|
lastStatus = proxyTransportRetryErrorStatus(error);
|
|
506
622
|
const proxyUsed = Boolean((error as NativeHttpAttemptError).proxyUsed);
|
|
507
623
|
if (
|
|
508
|
-
attempt <
|
|
624
|
+
attempt < transportAttemptCap &&
|
|
509
625
|
shouldRetryProxyTransportAttempt({
|
|
510
626
|
error,
|
|
511
627
|
explicitRetry,
|
|
@@ -521,6 +637,13 @@ export function createHttpClient(
|
|
|
521
637
|
}
|
|
522
638
|
}
|
|
523
639
|
|
|
640
|
+
// Reached when the attempt cap is consumed without a terminal outcome —
|
|
641
|
+
// e.g. the final offsets of a partial allocation all resolved to
|
|
642
|
+
// already-tried endpoints and were skipped. Surface the last real transport
|
|
643
|
+
// failure rather than a synthetic exhaustion error.
|
|
644
|
+
if (lastError !== undefined) {
|
|
645
|
+
throw lastError;
|
|
646
|
+
}
|
|
524
647
|
throw new TransportError("HTTP retry exhausted without a terminal result", {
|
|
525
648
|
code: "retry_exhausted",
|
|
526
649
|
});
|
|
@@ -19,10 +19,18 @@ const PROXY_AUTH_IP_DENIED_PATTERN =
|
|
|
19
19
|
/\b(?:source|egress|client)\s+ip\b.{0,120}\b(?:deny|denied|unauthori[sz]ed|not\s+authori[sz]ed|white\s*list|allow\s*list)\b|\b(?:white\s*list|allow\s*list)\b.{0,120}\b(?:source|egress|client)\s+ip\b/i;
|
|
20
20
|
const PROXY_EDGE_AUTH_REJECTED_PATTERN =
|
|
21
21
|
/\bauth\s+ip\s+err\b|\bproxy\b.{0,120}\bauth(?:entication)?\b.{0,120}\b(?:reject(?:ed)?|fail(?:ed)?|invalid|den(?:y|ied)|unauthori[sz]ed)\b|\bauth(?:entication)?\b.{0,120}\b(?:reject(?:ed)?|fail(?:ed)?|invalid|den(?:y|ied)|unauthori[sz]ed)\b.{0,120}\bproxy\b/i;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
// Vendor host tokens that can appear in upstream error strings. Adding a proxy
|
|
23
|
+
// vendor updates every classifier below in one place. `proxy` is the generic
|
|
24
|
+
// fallback so vendor-agnostic messages still classify.
|
|
25
|
+
const PROXY_VENDOR_ALTERNATION = "smartproxy|nodemaven|proxy";
|
|
26
|
+
const PROXY_POOL_STALE_MESSAGE_PATTERN = new RegExp(
|
|
27
|
+
`\\bproxy\\b.{0,120}\\b(?:pool|lease|expired|unavailable|exhausted|non[\\s-]?200\\s+code:\\s*(?:509|512))\\b|\\bnon[\\s-]?200\\s+code:\\s*(?:509|512)\\b.{0,120}\\bproxy\\b|\\b(?:${PROXY_VENDOR_ALTERNATION})\\b.{0,120}\\b(?:509|512)\\b`,
|
|
28
|
+
"i",
|
|
29
|
+
);
|
|
30
|
+
const PROXY_EDGE_TLS_REJECTED_MESSAGE_PATTERN = new RegExp(
|
|
31
|
+
`\\b(?:${PROXY_VENDOR_ALTERNATION})\\b.{0,160}\\b(?:495|ssl|tls|cert(?:ificate)?|handshake|edge|connect|non[\\s-]?200)\\b|\\b(?:495|ssl|tls|cert(?:ificate)?|handshake|edge|connect|non[\\s-]?200)\\b.{0,160}\\b(?:${PROXY_VENDOR_ALTERNATION})\\b`,
|
|
32
|
+
"i",
|
|
33
|
+
);
|
|
26
34
|
|
|
27
35
|
export function isProxyAuthIpDeniedMessage(message: string): boolean {
|
|
28
36
|
return PROXY_AUTH_IP_DENIED_PATTERN.test(message);
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { ProviderProxyPolicy } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export const NODEMAVEN_USERNAME_ENV = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
|
|
6
|
+
export const NODEMAVEN_PASSWORD_ENV = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
|
|
7
|
+
export const NODEMAVEN_FILTER_ENV = "APIFUSE__PROXY__NODEMAVEN_FILTER";
|
|
8
|
+
|
|
9
|
+
export const NODEMAVEN_GATEWAY_HOST = "gate.nodemaven.com";
|
|
10
|
+
|
|
11
|
+
/** Both schemes tunnel bytes end-to-end, preserving the client TLS handshake. */
|
|
12
|
+
export type ProxyProtocol = "http" | "socks5";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* NodeMaven's fastest protocol: HTTP CONNECT. Benchmarks (KR, cold + warm)
|
|
16
|
+
* showed socks5 through the gateway adds ~500ms per request over http, so
|
|
17
|
+
* NodeMaven never defaults to socks5.
|
|
18
|
+
*/
|
|
19
|
+
export const NODEMAVEN_DEFAULT_PROTOCOL: ProxyProtocol = "http";
|
|
20
|
+
|
|
21
|
+
/** NodeMaven gateway port ranges per protocol (docs: HTTP 8080-9080, SOCKS5 1080-2080). */
|
|
22
|
+
const NODEMAVEN_PORTS: Record<ProxyProtocol, { min: number; max: number }> = {
|
|
23
|
+
http: { min: 8080, max: 9080 },
|
|
24
|
+
socks5: { min: 1080, max: 2080 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const NODEMAVEN_FILTERS = new Set(["medium", "high"]);
|
|
28
|
+
const DEFAULT_NODEMAVEN_FILTER = "medium";
|
|
29
|
+
const DEFAULT_NODEMAVEN_POOL_SIZE = 20;
|
|
30
|
+
export const NODEMAVEN_MAX_POOL_SIZE = 50;
|
|
31
|
+
/** NodeMaven sticky sessions persist up to 24h server-side, keyed by the sid. */
|
|
32
|
+
const NODEMAVEN_MAX_LIFETIME_MINUTES = 1440;
|
|
33
|
+
const SID_LENGTH = 10;
|
|
34
|
+
|
|
35
|
+
export function hasNodemavenCredentials(): boolean {
|
|
36
|
+
return Boolean(readNodemavenUsername() && readNodemavenPassword());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readNodemavenUsername(): string | undefined {
|
|
40
|
+
return process.env[NODEMAVEN_USERNAME_ENV]?.trim() || undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readNodemavenPassword(): string | undefined {
|
|
44
|
+
return process.env[NODEMAVEN_PASSWORD_ENV]?.trim() || undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveNodemavenFilter(): string {
|
|
48
|
+
const raw = process.env[NODEMAVEN_FILTER_ENV]?.trim().toLowerCase();
|
|
49
|
+
if (!raw) return DEFAULT_NODEMAVEN_FILTER;
|
|
50
|
+
if (!NODEMAVEN_FILTERS.has(raw)) {
|
|
51
|
+
throw new Error(`${NODEMAVEN_FILTER_ENV} must be "medium" or "high"`);
|
|
52
|
+
}
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function nodemavenPoolSize(policy: ProviderProxyPolicy): number {
|
|
57
|
+
return Math.min(
|
|
58
|
+
NODEMAVEN_MAX_POOL_SIZE,
|
|
59
|
+
Math.max(1, Math.floor(policy.session?.poolSize ?? DEFAULT_NODEMAVEN_POOL_SIZE)),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function nodemavenLifetimeMinutes(policy: ProviderProxyPolicy): number {
|
|
64
|
+
const configured = policy.session?.lifetimeMinutes;
|
|
65
|
+
if (typeof configured !== "number" || !Number.isFinite(configured) || configured <= 0) {
|
|
66
|
+
return NODEMAVEN_MAX_LIFETIME_MINUTES;
|
|
67
|
+
}
|
|
68
|
+
return Math.min(NODEMAVEN_MAX_LIFETIME_MINUTES, Math.max(1, Math.floor(configured)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** NodeMaven username tokens accept `[a-z0-9]`; slugify geo values to that set. */
|
|
72
|
+
function slugifyGeo(value: string | undefined): string | undefined {
|
|
73
|
+
if (!value) return undefined;
|
|
74
|
+
const slug = value
|
|
75
|
+
.trim()
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
78
|
+
return slug || undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isStickyAffinity(policy: ProviderProxyPolicy): boolean {
|
|
82
|
+
return (policy.session?.affinity ?? "request") !== "request";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A sticky sid is deterministic from the affinity key so every process serving
|
|
87
|
+
* the same connection derives the same egress IP without shared storage. A
|
|
88
|
+
* rotating sid is random per call (a fresh egress IP per request).
|
|
89
|
+
*/
|
|
90
|
+
function deriveSid(
|
|
91
|
+
policy: ProviderProxyPolicy,
|
|
92
|
+
affinityKey: string | undefined,
|
|
93
|
+
poolIndex: number,
|
|
94
|
+
refreshEpoch: number,
|
|
95
|
+
): string {
|
|
96
|
+
if (!isStickyAffinity(policy) || !affinityKey) {
|
|
97
|
+
return randomBytes(SID_LENGTH).toString("hex").slice(0, SID_LENGTH);
|
|
98
|
+
}
|
|
99
|
+
const digest = createHash("sha256")
|
|
100
|
+
.update(`${affinityKey}:${refreshEpoch}:${poolIndex}`)
|
|
101
|
+
.digest("hex");
|
|
102
|
+
// hex digits are a subset of the allowed [a-z0-9] sid charset.
|
|
103
|
+
return digest.slice(0, SID_LENGTH);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function selectPort(protocol: ProxyProtocol, sid: string, poolIndex: number): number {
|
|
107
|
+
const { min, max } = NODEMAVEN_PORTS[protocol];
|
|
108
|
+
const span = max - min + 1;
|
|
109
|
+
const hashInt = Number.parseInt(
|
|
110
|
+
createHash("sha256").update(`${sid}:${poolIndex}`).digest("hex").slice(0, 8),
|
|
111
|
+
16,
|
|
112
|
+
);
|
|
113
|
+
return min + (hashInt % span);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type NodemavenSynthesisInput = {
|
|
117
|
+
policy: ProviderProxyPolicy;
|
|
118
|
+
affinityKey: string | undefined;
|
|
119
|
+
protocol: ProxyProtocol;
|
|
120
|
+
poolIndex: number;
|
|
121
|
+
refreshEpoch: number;
|
|
122
|
+
/** ISO 3166-1 alpha-2, already resolved by the caller (falls back to env). */
|
|
123
|
+
country?: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type NodemavenSynthesis = {
|
|
127
|
+
url: string;
|
|
128
|
+
protocol: ProxyProtocol;
|
|
129
|
+
diagnostics: Record<string, string | number | boolean>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Synthesize a NodeMaven gateway proxy URL locally from static credentials.
|
|
134
|
+
* There is no allocation API — geo/session are encoded in the username.
|
|
135
|
+
*/
|
|
136
|
+
export function synthesizeNodemavenProxy(input: NodemavenSynthesisInput): NodemavenSynthesis {
|
|
137
|
+
const username = readNodemavenUsername();
|
|
138
|
+
const password = readNodemavenPassword();
|
|
139
|
+
if (!username || !password) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`NodeMaven credentials missing: set ${NODEMAVEN_USERNAME_ENV} and ${NODEMAVEN_PASSWORD_ENV}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const filter = resolveNodemavenFilter();
|
|
146
|
+
const sid = deriveSid(input.policy, input.affinityKey, input.poolIndex, input.refreshEpoch);
|
|
147
|
+
const port = selectPort(input.protocol, sid, input.poolIndex);
|
|
148
|
+
const lifetimeMinutes = nodemavenLifetimeMinutes(input.policy);
|
|
149
|
+
|
|
150
|
+
const country = slugifyGeo(input.country ?? input.policy.geo?.country);
|
|
151
|
+
const region = slugifyGeo(input.policy.geo?.subdivision);
|
|
152
|
+
const city = slugifyGeo(input.policy.geo?.city);
|
|
153
|
+
|
|
154
|
+
const tokens = [username];
|
|
155
|
+
if (country) tokens.push("country", country);
|
|
156
|
+
if (region) tokens.push("region", region);
|
|
157
|
+
if (city) tokens.push("city", city);
|
|
158
|
+
tokens.push("sid", sid);
|
|
159
|
+
tokens.push("filter", filter);
|
|
160
|
+
tokens.push("ipv4", "true");
|
|
161
|
+
const proxyUsername = tokens.join("-");
|
|
162
|
+
|
|
163
|
+
// Username tokens are [a-z0-9-] only, which survive URL encoding unchanged.
|
|
164
|
+
const url = `${input.protocol}://${proxyUsername}:${encodeURIComponent(password)}@${NODEMAVEN_GATEWAY_HOST}:${port}`;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
url,
|
|
168
|
+
protocol: input.protocol,
|
|
169
|
+
diagnostics: {
|
|
170
|
+
vendor: "nodemaven",
|
|
171
|
+
protocol: input.protocol,
|
|
172
|
+
sticky: isStickyAffinity(input.policy),
|
|
173
|
+
filter,
|
|
174
|
+
lifetimeMinutes,
|
|
175
|
+
...(country ? { country } : {}),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|