mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 20:32:25 +03:00
Behind the new `PROXY_SKIP_RECENTLY_FAILED` flag (default off), pool rotation and the opencode account rotation remember a proxy that just failed (refused probe or 429) and skip it for a doubling cooldown instead of re-serving it immediately.
Maintainer rework before merge (kept the idea, no default behavior change):
- The original was on by default and re-queried the DB on every request while a member was set aside; selection now caches a refusal sequence number and re-runs the cascade once per set-aside event.
- `src/lib/db` no longer imports the heavy dispatcher for key normalization (a parity test guarantees the same key as `proxyConfigToUrl()`); `.env.example` and `ENVIRONMENT.md` document the default as false.
Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests.
Thanks @maxmad64bis!
199 lines
8.0 KiB
TypeScript
199 lines
8.0 KiB
TypeScript
/**
|
|
* Short-lived, per-process memory of proxies that just failed, shared by both places that
|
|
* pick a proxy: registry pools (#6365) and the per-account rotation of noauth executors.
|
|
* A failed proxy is set aside for a period that doubles on each repeat, up to a cap, then
|
|
* comes back. Nothing is persisted and no proxy status is written: only the order in which
|
|
* candidates are tried changes. Keys are entry points (scheme, username, host, port),
|
|
* never passwords.
|
|
*
|
|
* This module is a pure store: it never reads the PROXY_SKIP_RECENTLY_FAILED feature flag
|
|
* (callers gate writes and decisions on it) and it stays free of the proxy dispatcher, so
|
|
* the DB layer can consult it without loading undici or the SOCKS connector.
|
|
*/
|
|
import { COOLDOWN_MS } from "../config/errorConfig.ts";
|
|
import { stripIpv6Brackets } from "./proxyFamily.ts";
|
|
|
|
export const REFUSAL_POLICIES = {
|
|
/** The TCP probe could not open a connection to the proxy. */
|
|
proxy_unreachable: { baseMs: 60_000, maxMs: 600_000 },
|
|
/** The provider refused through this proxy; the member is set aside for a cooldown. */
|
|
ip_quota_429: { baseMs: COOLDOWN_MS.rateLimit, maxMs: 3_600_000 },
|
|
} as const;
|
|
|
|
export type ProxyRefusalKind = keyof typeof REFUSAL_POLICIES;
|
|
|
|
// `seq` orders set-aside events so a cache can tell whether it already saw this one.
|
|
type RefusalState = { streak: number; until: number; seq: number };
|
|
|
|
const MAX_ENTRIES = 1000;
|
|
const REFUSAL_KINDS = Object.keys(REFUSAL_POLICIES) as ProxyRefusalKind[];
|
|
// Same protocol set and default ports as proxyConfigToUrl() in proxyDispatcher.ts.
|
|
const DEFAULT_PORTS: Record<string, string> = { http: "8080", https: "443", socks5: "1080" };
|
|
const RELAY_TYPES = new Set(["vercel", "deno", "cloudflare"]);
|
|
const FAMILY_MARKER = /\?family=(ipv4|ipv6)$/;
|
|
|
|
const memory = new Map<string, RefusalState>();
|
|
let refusalSeq = 0;
|
|
|
|
const textField = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
|
|
// The port as proxyConfigToUrl() normalizes it: the scheme default when unset, null if invalid.
|
|
function configPort(port: unknown, type: string): string | null {
|
|
if (!port) return DEFAULT_PORTS[type] ?? "";
|
|
const parsed = Number(port);
|
|
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? String(parsed) : null;
|
|
}
|
|
|
|
// A config object as the URL proxyConfigToUrl() would build from it; null when unusable.
|
|
function configObjectToUrl(proxy: Record<string, unknown>): string | null {
|
|
const host = textField(proxy.host);
|
|
const type = String(proxy.type || "http").toLowerCase();
|
|
const port = configPort(proxy.port, type);
|
|
if (!host || RELAY_TYPES.has(type) || port === null) return null;
|
|
const bracketed = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
const username = textField(proxy.username);
|
|
const password = textField(proxy.password);
|
|
const auth =
|
|
username || password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : "";
|
|
return `${type}://${auth}${bracketed}:${port}`;
|
|
}
|
|
|
|
// The port written in the authority, which `new URL()` drops when it is the scheme default.
|
|
function explicitPortOf(url: string): string | null {
|
|
const start = url.indexOf("://");
|
|
if (start === -1) return null;
|
|
const rest = url.slice(start + 3);
|
|
const slash = rest.indexOf("/");
|
|
const authority = slash === -1 ? rest : rest.slice(0, slash);
|
|
const colon = authority.lastIndexOf(":");
|
|
if (colon === -1 || colon < authority.lastIndexOf("@") || colon < authority.lastIndexOf("]")) {
|
|
return null;
|
|
}
|
|
const port = Number(authority.slice(colon + 1));
|
|
return /^\d+$/.test(authority.slice(colon + 1)) && port >= 1 && port <= 65535
|
|
? String(port)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* One key per proxy entry point, whether the proxy comes as a config object, a URL or a
|
|
* legacy string: scheme, decoded username, lower-case host without IPv6 brackets, port as
|
|
* normalization writes it. Password and ?family= are ignored. Anything unusable, and edge
|
|
* relays, give null, which never sets anything aside.
|
|
*/
|
|
export function proxyEgressKey(proxy: unknown): string | null {
|
|
try {
|
|
let url: string | null = null;
|
|
if (typeof proxy === "string") url = proxy.trim();
|
|
else if (proxy && typeof proxy === "object" && !Array.isArray(proxy)) {
|
|
url = configObjectToUrl(proxy as Record<string, unknown>);
|
|
}
|
|
if (!url) return null;
|
|
url = url.replace(FAMILY_MARKER, "");
|
|
const parsed = new URL(url);
|
|
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
const defaultPort = DEFAULT_PORTS[scheme];
|
|
if (!defaultPort || !parsed.hostname) return null;
|
|
const port = explicitPortOf(url) || parsed.port || defaultPort;
|
|
const user = parsed.username ? decodeURIComponent(parsed.username) : "";
|
|
return `${scheme}://${user}@${stripIpv6Brackets(parsed.hostname).toLowerCase()}:${port}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function entryId(key: string, kind: ProxyRefusalKind): string {
|
|
return `${kind} ${key}`;
|
|
}
|
|
|
|
// Read one (key, kind) state, dropping it once its period ended more than 2 x maxMs ago.
|
|
function readState(key: string, kind: ProxyRefusalKind, nowMs: number): RefusalState | undefined {
|
|
const id = entryId(key, kind);
|
|
const state = memory.get(id);
|
|
if (state && nowMs - state.until >= 2 * REFUSAL_POLICIES[kind].maxMs) {
|
|
memory.delete(id);
|
|
return undefined;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
/** Set a proxy aside for `kind`. Returns the new period in ms, or null if nothing changed. */
|
|
export function noteProxyRefusal(
|
|
key: string | null,
|
|
kind: ProxyRefusalKind,
|
|
nowMs: number = Date.now()
|
|
): number | null {
|
|
if (key === null) return null;
|
|
const state = readState(key, kind, nowMs);
|
|
if (state && state.until > nowMs) return null;
|
|
const policy = REFUSAL_POLICIES[kind];
|
|
const streak = (state?.streak ?? 0) + 1;
|
|
const periodMs = Math.min(policy.baseMs * 2 ** (streak - 1), policy.maxMs);
|
|
const id = entryId(key, kind);
|
|
memory.delete(id);
|
|
memory.set(id, { streak, until: nowMs + periodMs, seq: ++refusalSeq });
|
|
if (memory.size > MAX_ENTRIES) {
|
|
const oldest = memory.keys().next().value;
|
|
if (oldest !== undefined) memory.delete(oldest);
|
|
}
|
|
return periodMs;
|
|
}
|
|
|
|
/** The proxy answered again: end its period now, keep the streak so a repeat doubles. */
|
|
export function noteProxyRecovered(
|
|
key: string | null,
|
|
kind: ProxyRefusalKind,
|
|
nowMs: number = Date.now()
|
|
): void {
|
|
if (key === null) return;
|
|
const state = readState(key, kind, nowMs);
|
|
if (state && state.until > nowMs) state.until = nowMs;
|
|
}
|
|
|
|
/** A response came back through this proxy: forget every refusal kind for it. */
|
|
export function noteProxyServed(key: string | null): void {
|
|
if (key === null) return;
|
|
for (const kind of REFUSAL_KINDS) memory.delete(entryId(key, kind));
|
|
}
|
|
|
|
export function isProxyAvoided(key: string | null, nowMs: number = Date.now()): boolean {
|
|
return proxySetAsideSeq(key, nowMs) !== null;
|
|
}
|
|
|
|
/**
|
|
* Sequence number of the most recent set-aside event still in force for this proxy, or
|
|
* null when it is not set aside. Compare with getProxyRefusalSeq() captured earlier to
|
|
* know whether the event happened after that point.
|
|
*/
|
|
export function proxySetAsideSeq(key: string | null, nowMs: number = Date.now()): number | null {
|
|
if (key === null || memory.size === 0) return null;
|
|
let latest: number | null = null;
|
|
for (const kind of REFUSAL_KINDS) {
|
|
const state = readState(key, kind, nowMs);
|
|
if (state && state.until > nowMs && (latest === null || state.seq > latest)) {
|
|
latest = state.seq;
|
|
}
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
/** Sequence number of the last set-aside event recorded in this process (0 = none yet). */
|
|
export function getProxyRefusalSeq(): number {
|
|
return refusalSeq;
|
|
}
|
|
|
|
/** True when anything is held at all: lets hot paths skip key computation and flag reads. */
|
|
export function hasProxyRefusals(): boolean {
|
|
return memory.size > 0;
|
|
}
|
|
|
|
/** Test-only: forget everything. */
|
|
export function __resetProxyRefusalMemoryForTesting(): void {
|
|
memory.clear();
|
|
}
|
|
|
|
/** Test-only: number of (key, kind) entries held. */
|
|
export function __proxyRefusalMemorySizeForTesting(): number {
|
|
return memory.size;
|
|
}
|