Repository navigation
v1.9.0
v1.9.0 — Egress proxies actually reach the browser
Four proxy surfaces shipped through v1.8.0 and only one of them reached a browser. This release makes the per-request proxy real end to end, and corrects the docs that described behaviour the code never had.
The bug, precisely
| Surface | Before v1.9.0 |
|---|---|
PX_PROXIES → ProxyPool → SessionPool |
worked, /v1/fetch sessions only |
POST /v1/solve body "proxy" |
deserialized into SolveRequestDto, never read |
px-cli solve --proxy |
sent in the request body, dropped server-side |
pxsolver-core::SolveRequest::with_proxy() |
published builder with zero consumers |
HarvestRequest.proxy |
honoured by CamoufoxPool, ignored by ChromiumoxidePool |
The cause was structural rather than a missed call site: SolveDispatcher::solve took &str and ChallengeHandler::solve took &PageHtml, so a proxy had no parameter to travel in, and both browser handlers built HarvestRequest::new(url) — which defaults proxy to None. ChromiumoxidePool::launch_browser had no --proxy-server argument at all.
What's new
- Per-request egress on
/v1/solve.ChallengeHandler::solvenow takes aSolveAction { page, proxy }constructed at the HTTP edge;SolveDispatcher::solvetakespxsolver_core::SolveRequest, so the published builder is the type the edge maps into. Chromium, Camoufox and the native sensor path all honour it. - Native path proxying.
SolveContextcarries the proxy andSensorNativeSolverposts through a per-proxyreqwestclient, cached inProxyClientsso the pooled TLS connection survives repeat solves. - The egress is part of the cache key. A bundle earned through proxy A is no longer replayed to a caller who asked for proxy B. A direct solve keeps
fp_key = 0, so existing entries still resolve. (This is the fix for the reported "differentproxyvalues return the same cached bundle" behaviour.) - Credentials are handled honestly. No browser engine can answer a proxy
407— geckodriver's W3Cproxycapability has no credential field and Chromium ignores userinfo without a CDPFetch.authRequiredhandler.user:pass@is now stripped with a warning naming the sanitized URL.reqwestdoes support proxy auth, so the native sensor path is exempt. socks5h://is normalized for Chromium, which has no such scheme and silently ignores specs it cannot parse.
Deliberately unchanged
/v1/solve never falls back to the PX_PROXIES rotation. A _px3 bundle is bound to the IP that earned it, so a caller who named no egress could not use a bundle harvested through a rotating one. Rotation stays where it pays: long-lived /v1/fetch Camoufox sessions, which consume the bundle themselves.
Docs corrected
docs/deployment.mdclaimedN × len(proxies)parallel egress paths. A session takes its proxy at spawn and holds it for the 300s TTL, so distinct egress IPs per domain ismin(PX_FETCH_MAX_PER_DOMAIN, len(PX_PROXIES))— ten proxies at the defaultN=2gives a domain two IPs, not twenty.- The
PX_PROXIESexample showeduser:pass@for paths that cannot authenticate. docs/runbook-native-bypass.mdtold operatorsPX_PROXIEScovered the solve path, and its throughput soak built a directreqwestclient while claiming to run through their proxy. The soak now readsNATIVE_SOAK_PROXY(or the firstPX_PROXIESentry) and prints the egress it used.- README gains a Proxies section. Rationale: ADR-0025.
⚠️ Source-breaking despite the minor bump
ChallengeHandler::solve and SolveDispatcher::solve changed signature. If you implement ChallengeHandler outside this workspace:
// before
async fn solve(&self, page: &PageHtml) -> Result<HandlerOutcome, AppError>
// after
async fn solve(&self, action: &SolveAction) -> Result<HandlerOutcome, AppError>
// action.page is the old argument; action.proxy is the requested egressEverything else in the published surface is unchanged. A pxsolver-* = "1" pin will fail to compile on cargo update — this was shipped as a minor knowingly, and the trade is recorded in ADR-0025.
Release mechanics
xtask bump only ever rewrote [workspace.package] version, leaving the internal pxsolver-* dependency pins at 1.4.0 since that release. Minor bumps hid it (1.8.0 satisfies ^1.4.0). bump now re-pins them with the workspace version, and all pins track 1.9.0.
Also clears RUSTSEC-2026-0185 (quinn-proto) and RUSTSEC-2026-0190 (anyhow) — both transitive, lock-only.
Verification
cargo fmt --check · cargo clippy --workspace --all-targets --all-features under the unwrap/expect/panic ban, zero warnings · full workspace test suite, zero failures · 200-LOC gate green · cargo audit --deny warnings clean. New tests cover the proxy reaching the routing dispatcher, the Cloudflare harvester and the native solver, the --proxy-server argument shape and socks5h normalization, egress-scoped cache entries, and absent optionals staying off the wire.
PRs merged
- #20 —
feat(proxy): honour the per-request egress proxy end to end
Published to crates.io
All 16 pxsolver-* library crates are published at 1.9.0. The pxsolver-server and
pxsolver-cli binaries are publish = false — build them from source.
Commits since v1.8.0:
- Merge pull request #20 from KeyCode17/feat/proxy-end-to-end (f5b3ee8)
- chore(deps): clear RUSTSEC-2026-0185 and RUSTSEC-2026-0190 (2094431)
- chore: bump to 1.9.0 (d6e263a)
- fix(proxy): stop two silent drops on the wired-up egress path (24be22e)
- style(errors): capitalize error messages in the proxy-touched harvester files (f534b63)
- fix(xtask): re-pin internal deps when bumping the workspace version (ed4bf67)
- feat(proxy): honour the per-request egress proxy end to end (9d3cdba)