fix: free proxy pool silent sync failure — iplocate txt + per-source isolation + surface errors (#5595) (#5686)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-30 13:46:46 -03:00
committed by GitHub
parent c568ab9cbb
commit 0489e88d97
7 changed files with 193 additions and 22 deletions

View File

@@ -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))

View File

@@ -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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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 ?? "";
}
});

View File

@@ -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", () => {