Skip to content

feat(proxy): honour the per-request egress proxy end to end - #20

Merged
KeyCode17 merged 7 commits into
mainfrom
feat/proxy-end-to-end
Aug 7, 2026
Merged

feat(proxy): honour the per-request egress proxy end to end#20
KeyCode17 merged 7 commits into
mainfrom
feat/proxy-end-to-end

Conversation

@KeyCode17

Copy link
Copy Markdown
Owner

Why

Four proxy surfaces shipped through v1.8.0; only one reached a browser.

Surface Before
PX_PROXIESProxyPoolSessionPool worked, /v1/fetch sessions only
POST /v1/solve body "proxy" deserialized into SolveRequestDto, never read
px-cli solve --proxy sent in the body, dropped server-side
pxsolver_core::SolveRequest::with_proxy() published builder, zero consumers
HarvestRequest.proxy honoured by CamoufoxPool, ignored by ChromiumoxidePool

The cause was structural, not a missed call site: SolveDispatcher::solve took &str and ChallengeHandler::solve took &PageHtml, so no proxy could travel inward, and both browser handlers built HarvestRequest::new(url) — which defaults proxy to None. ChromiumoxidePool::launch_browser had no --proxy-server argument at all.

What changed

  • ChallengeHandler::solve takes a SolveAction { page, proxy } built at the HTTP edge; SolveDispatcher::solve takes px_core::SolveRequest, so the published builder is the type the edge maps into.
  • Both browser handlers forward it into HarvestRequest; Chromium gained --proxy-server; the native sensor path posts through a per-proxy reqwest client cached in ProxyClients.
  • Solving never falls back to the PX_PROXIES rotation. _px3 is bound to the IP that earned it, so a caller who named no egress could not use a rotating one. Rotation stays with /v1/fetch sessions.
  • The egress is part of the cache key (0 for direct, so existing entries still resolve) — otherwise a bundle earned through proxy A gets replayed for proxy B.
  • Proxy credentials are stripped with a warning: no browser engine can answer a proxy 407. reqwest can, so the native path is exempt.
  • socks5h:// is normalized for Chromium, which has no such scheme and silently ignores specs it cannot parse.

Docs corrected

  • deployment.md claimed N × len(proxies) parallel egress paths. SessionPool::acquire only draws a proxy when spawning, so it is min(PX_FETCH_MAX_PER_DOMAIN, len(PX_PROXIES)) — 10 proxies at the default N=2 gives a domain 2 IPs, not 20.
  • The credentials example showed user:pass@ for paths that cannot authenticate.
  • runbook-native-bypass.md told operators PX_PROXIES covered the solve path, and its soak built a direct client while claiming to run through their proxy.
  • README gains a Proxies section; rationale in ADR-0025.

Release mechanics

  • xtask bump only ever rewrote [workspace.package] version, leaving the internal dep pins at 1.4.0 since that release. Minor bumps hid it (1.8.0 satisfies ^1.4.0); the first major bump broke workspace resolution outright. bump now re-pins them.
  • Shipped as 1.9.0 despite being source-breaking — maintainer's call, recorded in ADR-0025. Implementors of ChallengeHandler outside this workspace must add the SolveAction parameter, so a pxsolver-* = "1" pin fails to compile on cargo update. README carries the warning.

Verification

cargo fmt --check · cargo clippy --workspace --all-targets --all-features under the unwrap/expect/panic ban, zero warnings · cargo test --workspace --all-features, 0 failures · 200-LOC gate green.

New coverage: proxy reaches the handler (routing), the harvester (cloudflare), the native solver; --proxy-server argument shape and socks5h normalization; egress-scoped cache entries; absent optionals stay off the wire.

Known deviation, unchanged here: #[allow(clippy::expect_used, …)] remains on test modules because lefthook runs those denies with --all-targets. The real fix is per-target lint config in the gate script — repo-wide, its own PR.

Three of four proxy surfaces were wired to nothing. `POST /v1/solve`
deserialized `"proxy"` into SolveRequestDto and never read it, `px-cli
solve --proxy` sent it in the body for the server to drop, and the
published `pxsolver_core::SolveRequest::with_proxy()` had zero consumers
in the workspace. `HarvestRequest.proxy` was honoured by CamoufoxPool and
silently ignored by ChromiumoxidePool, which built its BrowserConfig with
no --proxy-server argument at all.

The cause was structural: SolveDispatcher::solve took `&str` and
ChallengeHandler::solve took `&PageHtml`, so no proxy could travel
inward, and both browser handlers built HarvestRequest::new(url), which
defaults proxy to None.

ChallengeHandler::solve now takes a SolveAction { page, proxy } built at
the HTTP edge; SolveDispatcher::solve takes px_core::SolveRequest, making
the published builder the type the edge maps into. Both browser handlers
forward it, ChromiumoxidePool gained --proxy-server, and the native
sensor path posts through a per-proxy reqwest client cached in
ProxyClients.

Solving deliberately does not fall back to the PX_PROXIES rotation: _px3
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 with
/v1/fetch sessions. The proxy is hashed into the cache key so a bundle
earned through proxy A is never replayed for proxy B; a direct solve
keeps fp_key 0 and existing entries still resolve.

Browser engines cannot authenticate to a proxy — geckodriver's W3C proxy
capability has no credential field and Chromium ignores userinfo without
a CDP Fetch.authRequired handler — so strip_credentials removes
`user:pass@` and warns. reqwest does support it, so the native path is
exempt.

Docs corrected alongside: deployment.md claimed N x len(proxies) parallel
egress paths, but SessionPool::acquire only draws a proxy when spawning,
so distinct egress IPs per domain is min(PX_FETCH_MAX_PER_DOMAIN,
len(PX_PROXIES)). The native runbook told operators PX_PROXIES covered
the solve path, and its soak built a direct client while claiming to run
through their proxy.

Breaking: ChallengeHandler and SolveDispatcher signatures change. See
ADR-0025.
`bump` rewrote only `[workspace.package] version`, leaving the
`px-* = { path, version, package = "pxsolver-*" }` entries at whatever
they were last set to by hand — 1.4.0 since that release, while crates
were publishing at 1.8.0.

Minor bumps hid it: 1.8.0 still satisfies `^1.4.0`. A major bump does
not, so `cargo` fails to resolve the workspace the moment the version
crosses 2.0.0, with no obvious link back to the bump that caused it.

Every crate carries `version.workspace = true`, so the pins are meant to
move in lockstep; bump now rewrites them with the workspace version.
Post-1.0.0 architectural change per ADR-0017: ChallengeHandler::solve and
SolveDispatcher::solve change signature so the per-request egress proxy
reaches the browser and native paths (ADR-0025). Breaking for downstream
users of pxsolver-pipeline, -harvester, -native and -core.

Internal dependency pins move to 2.0.0 with the workspace version; they
had been stale at 1.4.0 since that release.
…er files

AppError::message() returns the payload verbatim as the whole user-facing
message, so each one starts a sentence.
Chromium has no `socks5h` scheme and silently ignores a `--proxy-server`
spec it cannot parse, so a `socks5h://` proxy — a scheme this API
documents and geckodriver accepts — would have gone direct with no error.
chromium_proxy_spec rewrites it to `socks5://`, and proxy_arg is now
covered: the Chromium leg was the one part of the wiring with no test,
and the part that had no proxy argument at all before.

SolveRequest also serialized its absent optionals as `null`. The CLI's
old private body struct skipped them, so switching it to the published
type put `"fingerprint":null` on the wire — a field the server has no
place for and silently discards, which is the same class of unkept
promise this change set out to remove.
Supersedes the 2.0.0 bump earlier in this branch. Maintainer's call to
ship the proxy contract as a minor, amending the ADR-0017 line that a
post-1.0.0 architectural change takes a manual major.

The break is real and stated rather than hidden: implementors of
ChallengeHandler outside this workspace must add the SolveAction
parameter, so a `pxsolver-* = "1"` pin fails to compile on cargo update.
README carries the warning; ADR-0025 records the trade.
anyhow 1.0.102 → 1.0.104 (unsound Error::downcast_mut) and quinn-proto
0.11.14 → 0.11.16 (remote memory exhaustion from unbounded out-of-order
stream reassembly).

Both are transitive and predate this branch — main carries the same
lock entries and fails the same audit, since the advisory database is
fetched fresh on every run. Lock-only; no manifest requirement moves.
@KeyCode17
KeyCode17 merged commit f5b3ee8 into main Aug 7, 2026
6 checks passed
@KeyCode17
KeyCode17 deleted the feat/proxy-end-to-end branch August 7, 2026 17:30
@KeyCode17 KeyCode17 mentioned this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant