mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
fix: free proxy pool silent sync failure — iplocate txt + per-source isolation + surface errors (#5595) (#5686)
This commit is contained in:
committed by
GitHub
parent
c568ab9cbb
commit
0489e88d97
@@ -10,6 +10,8 @@
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **dashboard (free proxy pool):** the free proxy pool "Sync All" no longer fails silently with `Total: 0`. Three fixes: (1) the **IPLocate** source fetched `…/protocols/<proto>.json` and parsed it as JSON, but the upstream list is plain text (`<proto>.txt`, one `ip:port` per line) — every protocol 404'd / failed to parse; it now fetches `.txt` and parses the line list. (2) The sync route **isolates each source** in its own try/catch, so one provider throwing (e.g. a TLS handshake failure) no longer aborts the whole sync — the working sources still populate the pool. (3) The UI now **surfaces the per-source errors** the route already returns, instead of discarding the response, so a partial/empty sync explains itself. Regression guards: `tests/unit/free-proxy-providers.test.ts`, `tests/unit/proxy-pool-sync-4878.test.ts`, `tests/unit/free-pool-tab.test.tsx`. ([#5595](https://github.com/diegosouzapw/OmniRoute/issues/5595))
|
||||
|
||||
- **dashboard (memory engine):** the memory engine status page no longer mixes English and Portuguese. The embedding / vector-store / rerank **status detail strings** were hardcoded in Portuguese in the backend (`resolveEmbeddingSource`, `engineStatus`), e.g. `auto: nenhuma fonte de embedding disponível` and `sqlite-vec ativo, dim=…`, while the surrounding UI labels render from the English i18n bundle — so an English user saw a half-translated page. The backend detail strings are now English (`auto: no embedding source available`, `sqlite-vec active, dim=…`, etc.), matching the rest of the page. Regression guard: `tests/unit/memory-engine-status.test.ts`. ([#5596](https://github.com/diegosouzapw/OmniRoute/issues/5596))
|
||||
|
||||
- **providers (cline):** stop falsely mapping valid **Cline** (OAuth) responses to `502 empty_choices` + account cooldown. `detectMalformedNonStream` only recognized `choices[].message.content` as a **string**, but some OpenAI-compatible upstreams — Cline via OAuth among them — return `content` as an **array of Anthropic-style text blocks** inside an OpenAI envelope. A non-empty response (recvBytes > 0) was therefore classified as `empty_choices` and turned into a 502 that also cooled the account down. The malformed-response detector now also treats a content array carrying at least one non-empty `text` block as real output. Regression guard: `tests/unit/diagnostics.test.ts`. ([#5559](https://github.com/diegosouzapw/OmniRoute/issues/5559))
|
||||
|
||||
@@ -30,6 +30,8 @@ export default function FreePoolTab() {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [addingIds, setAddingIds] = useState<Set<string>>(new Set());
|
||||
const [bulkProgress, setBulkProgress] = useState<string | null>(null);
|
||||
// #5595: per-source sync errors so a "Total: 0" result is never silent.
|
||||
const [syncErrors, setSyncErrors] = useState<Record<string, string[]> | null>(null);
|
||||
|
||||
// Load persisted disabled-sources from localStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -83,14 +85,27 @@ export default function FreePoolTab() {
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncErrors(null);
|
||||
try {
|
||||
const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
|
||||
const body = enabledSources.length < ALL_SOURCE_IDS.length ? { sources: enabledSources } : {};
|
||||
await fetch("/api/settings/free-proxies/sync", {
|
||||
const res = await fetch("/api/settings/free-proxies/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// #5595: surface per-source errors the route already returns so a
|
||||
// partial/empty sync explains itself instead of silently showing "Total: 0".
|
||||
const data = await res.json().catch(() => null);
|
||||
if (data?.results) {
|
||||
const errs: Record<string, string[]> = {};
|
||||
for (const [src, r] of Object.entries(
|
||||
data.results as Record<string, { errors?: string[] }>
|
||||
)) {
|
||||
if (Array.isArray(r?.errors) && r.errors.length > 0) errs[src] = r.errors;
|
||||
}
|
||||
if (Object.keys(errs).length > 0) setSyncErrors(errs);
|
||||
}
|
||||
await loadData();
|
||||
} catch {}
|
||||
setSyncing(false);
|
||||
@@ -208,6 +223,20 @@ export default function FreePoolTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncErrors && (
|
||||
<div
|
||||
className="text-xs text-red-500 flex flex-col gap-1"
|
||||
role="alert"
|
||||
data-testid="free-pool-sync-errors"
|
||||
>
|
||||
{Object.entries(syncErrors).map(([src, errs]) => (
|
||||
<span key={src}>
|
||||
{src}: {errs.join("; ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-2 p-2 bg-primary/10 rounded border border-primary/20">
|
||||
<span className="text-xs">{t("proxyFreePoolSelected", { count: selected.size })}</span>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { freeProxySyncSchema } from "@/shared/validation/freeProxySchemas";
|
||||
import { getEnabledProviders, getProvider } from "@/lib/freeProxyProviders";
|
||||
import { recordFreeProxySync } from "@/lib/localDb";
|
||||
import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types";
|
||||
import type { FreeProxyProvider, FreeProxySourceId } from "@/lib/freeProxyProviders/types";
|
||||
|
||||
let _providersOverrideForTests: FreeProxyProvider[] | null = null;
|
||||
export function _setProvidersForTests(providers: FreeProxyProvider[] | null): void {
|
||||
_providersOverrideForTests = providers;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
@@ -35,15 +40,28 @@ export async function POST(request: Request) {
|
||||
|
||||
try {
|
||||
const providers =
|
||||
validation.data.sources && validation.data.sources.length > 0
|
||||
_providersOverrideForTests ??
|
||||
(validation.data.sources && validation.data.sources.length > 0
|
||||
? validation.data.sources
|
||||
.map((id) => getProvider(id as FreeProxySourceId))
|
||||
.filter((p): p is NonNullable<typeof p> => p != null)
|
||||
: getEnabledProviders();
|
||||
: getEnabledProviders());
|
||||
|
||||
const results: Record<string, unknown> = {};
|
||||
for (const provider of providers) {
|
||||
results[provider.id] = await provider.sync();
|
||||
try {
|
||||
results[provider.id] = await provider.sync();
|
||||
} catch (error) {
|
||||
// #5595: isolate per-source failures so one provider throwing doesn't
|
||||
// abort the whole sync — the other sources still populate the pool and
|
||||
// the failure is surfaced in `results` instead of a blanket 500.
|
||||
results[provider.id] = {
|
||||
fetched: 0,
|
||||
added: 0,
|
||||
updated: 0,
|
||||
errors: [error instanceof Error ? error.message : String(error)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// #4878: persist the sync timestamp so the UI's "last sync" advances even
|
||||
|
||||
@@ -8,12 +8,6 @@ const PROTOCOLS = ["http", "https", "socks4", "socks5"] as const;
|
||||
let lastFetchAt = 0;
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
type IplocateProxy = {
|
||||
ip: string;
|
||||
port: number;
|
||||
country: string;
|
||||
};
|
||||
|
||||
export class IplocateProvider implements FreeProxyProvider {
|
||||
readonly id = "iplocate" as const;
|
||||
readonly name = "IPLocate";
|
||||
@@ -46,7 +40,11 @@ export class IplocateProvider implements FreeProxyProvider {
|
||||
|
||||
for (const proto of PROTOCOLS) {
|
||||
try {
|
||||
const url = `${baseUrl}/${proto}.json`;
|
||||
// #5595: the iplocate/free-proxy-list repo serves plain-text `ip:port`
|
||||
// lists at `<proto>.txt` — the previous `<proto>.json` path 404'd on every
|
||||
// protocol (and `res.json()` would fail even if it didn't, since the
|
||||
// payload is not JSON).
|
||||
const url = `${baseUrl}/${proto}.txt`;
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers:
|
||||
@@ -59,21 +57,25 @@ export class IplocateProvider implements FreeProxyProvider {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as IplocateProxy[];
|
||||
if (!Array.isArray(data)) continue;
|
||||
|
||||
for (const p of data) {
|
||||
if (!p.ip || !p.port) continue;
|
||||
if (isPrivateHost(p.ip)) {
|
||||
errors.push(`${proto}: skipped private/loopback host ${p.ip}`);
|
||||
const text = await res.text();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const sep = trimmed.lastIndexOf(":");
|
||||
if (sep <= 0) continue;
|
||||
const host = trimmed.slice(0, sep).trim();
|
||||
const port = Number(trimmed.slice(sep + 1).trim());
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) continue;
|
||||
if (isPrivateHost(host)) {
|
||||
errors.push(`${proto}: skipped private/loopback host ${host}`);
|
||||
continue;
|
||||
}
|
||||
const item: FreeProxyItem = {
|
||||
source: "iplocate",
|
||||
host: p.ip,
|
||||
port: Number(p.port),
|
||||
host,
|
||||
port,
|
||||
type: proto,
|
||||
countryCode: p.country?.slice(0, 2).toUpperCase() || null,
|
||||
countryCode: null, // the txt list carries no country data
|
||||
qualityScore: null,
|
||||
latencyMs: null,
|
||||
anonymity: null,
|
||||
|
||||
@@ -237,3 +237,45 @@ describe("FreePoolTab data loading", () => {
|
||||
expect(el.textContent).toMatch(/In pool: 2/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FreePoolTab sync error surfacing (#5595)", () => {
|
||||
it("renders the per-source errors the sync route returns instead of failing silently", async () => {
|
||||
const mockFetch = vi.fn((url: string) => {
|
||||
if (String(url).includes("/sync")) {
|
||||
return okJson({
|
||||
success: true,
|
||||
results: {
|
||||
proxifly: { fetched: 0, added: 0, updated: 0, errors: ["TLS handshake failed"] },
|
||||
iplocate: { fetched: 0, added: 0, updated: 0, errors: ["http: HTTP 404"] },
|
||||
"1proxy": { fetched: 5, added: 5, updated: 0, errors: [] },
|
||||
},
|
||||
lastSyncAt: "2026-06-30T00:00:00Z",
|
||||
});
|
||||
}
|
||||
if (String(url).includes("/stats")) return okJson({ stats: defaultStats });
|
||||
return okJson({ items: [] });
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const el = renderTab();
|
||||
await waitForCondition(() => el.querySelector("[role='group']") !== null);
|
||||
|
||||
const syncBtn = Array.from(el.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("proxyFreePoolSyncAll")
|
||||
)!;
|
||||
expect(syncBtn).toBeTruthy();
|
||||
act(() => {
|
||||
syncBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// RED before the fix: handleSync discarded the response, so no error box appears.
|
||||
await waitForCondition(
|
||||
() => el.querySelector("[data-testid='free-pool-sync-errors']") !== null
|
||||
);
|
||||
const errBox = el.querySelector("[data-testid='free-pool-sync-errors']")!;
|
||||
expect(errBox.textContent).toContain("TLS handshake failed");
|
||||
expect(errBox.textContent).toContain("HTTP 404");
|
||||
// A source with no errors must not be listed.
|
||||
expect(errBox.textContent).not.toContain("1proxy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,3 +233,40 @@ test("IplocateProvider.sync returns disabled error when not enabled", async () =
|
||||
|
||||
process.env.FREE_PROXY_IPLOCATE_ENABLED = original ?? "";
|
||||
});
|
||||
|
||||
test("IplocateProvider.sync parses the plain-text ip:port lists (.txt, not .json) (#5595)", async () => {
|
||||
const original = process.env.FREE_PROXY_IPLOCATE_ENABLED;
|
||||
const originalFetch = globalThis.fetch;
|
||||
process.env.FREE_PROXY_IPLOCATE_ENABLED = "true";
|
||||
await reset();
|
||||
|
||||
const seenUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
seenUrls.push(String(input));
|
||||
// The iplocate/free-proxy-list repo serves one `ip:port` per line (no JSON).
|
||||
// Include a blank line and a comment to exercise line-skipping.
|
||||
const body = "103.173.141.10:8080\n8.211.49.86:9028\n\n# comment line\n";
|
||||
return new Response(body, { status: 200, headers: { "content-type": "text/plain" } });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const p = getProvider("iplocate")!;
|
||||
const result = await p.sync();
|
||||
// RED before the fix: the URL ended in `.json` and `res.json()` threw on the
|
||||
// plain-text payload → every protocol errored and 0 proxies were parsed.
|
||||
assert.ok(
|
||||
seenUrls.length > 0 && seenUrls.every((u) => u.endsWith(".txt")),
|
||||
`expected .txt URLs, got: ${seenUrls.join(", ")}`
|
||||
);
|
||||
assert.ok(result.fetched > 0, `expected proxies parsed from the txt list, got ${result.fetched}`);
|
||||
const items = await p.list({ limit: 50 });
|
||||
assert.ok(
|
||||
items.some((i) => i.host === "103.173.141.10" && i.port === 8080),
|
||||
"parsed ip:port must be stored"
|
||||
);
|
||||
assert.ok(items.every((i) => i.source === "iplocate"));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.FREE_PROXY_IPLOCATE_ENABLED = original ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ const freeProxiesDb = await import("../../src/lib/db/freeProxies.ts");
|
||||
const addToPoolRoute = await import(
|
||||
"../../src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts"
|
||||
);
|
||||
const syncRoute = await import("../../src/app/api/settings/free-proxies/sync/route.ts");
|
||||
const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts");
|
||||
|
||||
function reset() {
|
||||
@@ -124,6 +125,46 @@ test("#4878 recordFreeProxySync advances lastSyncAt on a subsequent sync", async
|
||||
assert.equal(stats.lastSyncAt, "2030-06-25T12:00:00.000Z");
|
||||
});
|
||||
|
||||
// ── #5595: a throwing source must not abort the whole sync ───────────────────
|
||||
|
||||
test("#5595 sync route isolates a throwing provider — others still sync, error surfaced", async () => {
|
||||
const makeProvider = (id: string, sync: () => Promise<unknown>) =>
|
||||
({ id, name: id, isEnabled: () => true, sync, list: async () => [] }) as unknown as Parameters<
|
||||
typeof syncRoute._setProvidersForTests
|
||||
>[0][number];
|
||||
|
||||
const good = makeProvider("1proxy", async () => ({
|
||||
fetched: 3,
|
||||
added: 3,
|
||||
updated: 0,
|
||||
errors: [],
|
||||
}));
|
||||
const bad = makeProvider("proxifly", async () => {
|
||||
throw new Error("TLS handshake failed");
|
||||
});
|
||||
|
||||
syncRoute._setProvidersForTests([bad, good]);
|
||||
try {
|
||||
const res = await syncRoute.POST(
|
||||
new Request("http://localhost/api/settings/free-proxies/sync", { method: "POST" })
|
||||
);
|
||||
// RED before the fix: the throwing provider escaped to the outer catch → 500,
|
||||
// and the working provider never ran.
|
||||
assert.equal(res.status, 200, `expected 200 (partial success), got ${res.status}`);
|
||||
const body = (await res.json()) as { success: boolean; results: Record<string, any> };
|
||||
assert.equal(body.success, true);
|
||||
// Working source still produced its result.
|
||||
assert.deepEqual(body.results["1proxy"], { fetched: 3, added: 3, updated: 0, errors: [] });
|
||||
// Failing source surfaced its error instead of aborting everything.
|
||||
assert.ok(
|
||||
body.results["proxifly"].errors.some((e: string) => e.includes("TLS handshake failed")),
|
||||
`expected the proxifly error surfaced, got: ${JSON.stringify(body.results["proxifly"])}`
|
||||
);
|
||||
} finally {
|
||||
syncRoute._setProvidersForTests(null);
|
||||
}
|
||||
});
|
||||
|
||||
// ── SUB-FIX 3: Redis error-log throttle is state-change-gated ─────────────────
|
||||
|
||||
test("#4878 shouldLogRedisError only logs once per error-state change", () => {
|
||||
|
||||
Reference in New Issue
Block a user