fix(auth): add User-Agent header to bypass Cloudflare 403 (closes #41)#42
fix(auth): add User-Agent header to bypass Cloudflare 403 (closes #41)#42alexandrosh8 wants to merge 5 commits intoPolymarket:mainfrom
Conversation
…loses Polymarket#41, Polymarket#38) - Add DEFAULT_USER_AGENT = "polymarket-clob-client-v2/{version}" to constants.py, resolved at import time via importlib.metadata with a setup.py fallback. - Replace the unversioned "py_clob_client_v2" UA in _overload_headers() with _resolve_user_agent(), which reads POLY_USER_AGENT env var first (operator override), then falls back to DEFAULT_USER_AGENT. - Add 17 regression tests in tests/test_user_agent_header.py covering the constant shape, env-var override, and every HTTP method path.
|
I set os.environ["POLY_USER_AGENT"] = "polymarket-clob-client-v2/1.0.1rc1" but still same error. Should i wait for new version puhlish? |
…e 2 of UA-bypass) Community evidence on Polymarket#41 (copperhuh, Zero-Ace5, lakeswimmer; calrde on this PR) confirmed that User-Agent header alone is insufficient against the Cloudflare WAF guarding /auth/api-key. Cloudflare scores TLS JA3, HTTP/2 frames, header order, and IP reputation alongside the UA — a clean UA on a datacenter IP still hits 403. This commit expands the original UA fix into a three-layer mitigation: 1. Versioned UA (already shipped in 6fedd7f). 2. Full browser-header bundle (Accept-*, sec-ch-ua-*, sec-fetch-*) on every request via _overload_headers, set with dict.setdefault so caller-supplied headers still win. 3. Opt-in residential-proxy support via POLY_AUTH_PROXY for the L1 /auth/api-key call ONLY (no order-path latency hit). Wired through a new build_auth_http_client(timeout_s) helper that returns an httpx.Client with proxies={"https://": url, "http://": url} when the env var is set; direct client otherwise. Tested against IPRoyal. Test count: 17 -> 26. New cases cover the browser bundle (sec-ch-ua, sec-fetch-*, Accept-Language, caller-override-wins) and the proxy support (env unset/whitespace/value; client built without proxies when unset; client built with proxies when set). PR description updated with the community evidence chain + IPRoyal as a tested provider. Refs: Polymarket#38, Polymarket#41, Polymarket#43; community-fix discovery 2026-05-02.
Cursor Bugbot review on PR Polymarket#42 commit ba44d13 surfaced 4 real bugs in the residential-proxy + browser-header bundle. All four are now fixed and covered by regression tests (32 → all green; 164 SDK tests overall pass with zero regressions). 1. HIGH — `str | None` (PEP 604) crashed Python 3.9 at import ---------------------------------------------------------- The project declares ``python_requires=">=3.9.10"`` but ``get_auth_proxy_url() -> str | None`` requires runtime PEP 604 support (Python 3.10+). On 3.9 it raised ``TypeError: unsupported operand type(s) for |`` at import time, taking the entire SDK down (client.py imports from this module). Fix: add ``from __future__ import annotations`` AND switch to ``Optional[str]`` (belt-and-braces — survives a future maintainer removing the future import). 2. MED — ``proxies={...}`` kwarg removed in httpx 0.28+ ---------------------------------------------------- The original ``httpx.Client(proxies={"https://": url, "http://": url})`` call raised ``TypeError: Client.__init__() got an unexpected keyword argument 'proxies'`` on httpx >=0.28 (Nov 2024). setup.py pins ``httpx[http2]>=0.27.0`` with no upper bound. Fix: switch to the new single-URL ``proxy=<url>`` kwarg, which is forward-compatible with httpx >=0.28 and back-compatible with httpx >=0.27. 3. MED — ``build_auth_http_client`` was dead code ---------------------------------------------- The proxy-aware client builder existed but was never called from ``create_api_key`` / ``derive_api_key``, so ``POLY_AUTH_PROXY`` had no effect. The PR claimed Layer 3 of the bypass was wired; in fact it was completely inert. Fix: introduce ``auth_request`` / ``auth_get`` / ``auth_post`` in helpers.py that build a per-call ``httpx.Client`` via ``build_auth_http_client()`` (proxy-aware, honours UA + browser headers). Wire ``ClobClient.create_api_key`` to ``auth_post`` and ``ClobClient.derive_api_key`` to ``auth_get`` — these are the only two surfaces Cloudflare 403's against datacenter IPs (Polymarket#38 / Polymarket#41 / Polymarket#43). Every other call still flows through the long-lived shared ``_http_client`` to amortise connection setup. Also export the new helpers from ``http_helpers/__init__.py`` so they appear in the public API surface. 4. MED — Brotli advertised without ``brotli`` dependency ----------------------------------------------------- ``Accept-Encoding: gzip, deflate, br`` told Cloudflare we accept Brotli, but ``brotli`` / ``brotlicffi`` is NOT in install_requires (httpx[http2] only). Cloudflare commonly responds with ``Content-Encoding: br`` when the client advertises support, and httpx then fails to decode the body (ImportError or UnicodeDecodeError). Fix: drop ``br`` from the advertised set. ``gzip, deflate`` is sufficient to look like a modern client without introducing a new mandatory dependency. (Operators who want Brotli can install the ``brotli`` extra themselves and patch the header.) New tests --------- - ``TestPython39Compat`` — pins ``from __future__ import annotations`` + ``Optional[str]`` annotation in helpers.py source. - ``test_get_does_not_advertise_brotli_without_dep`` — pins the ``Accept-Encoding`` value to ``gzip, deflate`` (no ``br``). - ``TestAuthRequestRoutesThroughProxy`` — three tests proving ``auth_post`` / ``auth_get`` actually invoke ``build_auth_http_client`` and inject the browser-header bundle. - Updated ``test_build_auth_http_client_uses_proxy_when_set`` to assert the new ``proxy=<url>`` kwarg (not the removed ``proxies={...}``). Test results: 32/32 in test_user_agent_header.py, 164 SDK tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bugbot fixes (commit
|
| # | Sev | Bug | Fix |
|---|---|---|---|
| 1 | HIGH | str | None (PEP 604) crashed Python 3.9 at import — python_requires=">=3.9.10" |
Added from __future__ import annotations + switched to Optional[str] (belt-and-braces) |
| 2 | MED | proxies={...} kwarg removed in httpx 0.28+ (no upper bound on httpx pin) |
Switched to proxy=<single url> kwarg (forward-compat 0.28+, back-compat 0.27+) |
| 3 | MED | build_auth_http_client defined but never called — Layer 3 was dead code |
Added auth_request / auth_post / auth_get in helpers.py and wired create_api_key → auth_post, derive_api_key → auth_get. Exported from __init__.py. |
| 4 | MED | Accept-Encoding: ..., br advertised Brotli without brotli in install_requires |
Dropped br; kept gzip, deflate (httpx ships built-in support) |
Tests
- 32/32 in
test_user_agent_header.py(addedTestPython39Compat,TestAuthRequestRoutesThroughProxy, plus the Brotli + newproxy=assertions) - 164/164 in the broader SDK test suite — zero regressions
Bypass surface now correctly assembled
- Layer 1 — versioned UA (
polymarket-clob-client-v2/{version}) - Layer 2 — full Chrome 124 browser-header bundle (
Accept,Accept-Language,sec-ch-ua*,sec-fetch-*,gzip+deflatefor GETs) - Layer 3 — opt-in residential proxy via
POLY_AUTH_PROXY(now actually wired intocreate_api_key/derive_api_key, the only two surfaces Cloudflare 403's against datacenter IPs per Cloudflare 403 on auth/api-key #38 / Cloudflare blocks API key creation (403) with py_clob_client_v2 #41 / signature_type=2 (POLY_GNOSIS_SAFE) — POST /order returns 401 with valid credentials #43)
Cursor Bugbot's 2nd review on commit `31134da` flagged that the new ``auth_post`` / ``auth_get`` helpers silently dropped the ``retry_on_error`` semantic that the pre-PR-Polymarket#42 ``self._post()`` / ``self._get()`` path forwarded from ``ClobClient(retry_on_error=True)``. Result: clients that opted in to a single transient-error retry on ``create_api_key`` lost that behaviour as soon as the auth call moved to the proxy-aware path. Fix --- - ``helpers.py``: extract per-attempt logic to ``_auth_request_attempt``; add ``retry_on_error: bool = False`` parameter to ``auth_request``, ``auth_get``, ``auth_post``. On a transient error (5xx response or ``httpx.ConnectError`` / ``TimeoutException`` / ``NetworkError``), wait 30 ms and try once more — identical contract to ``post()``. 4xx errors (e.g. 401 "Invalid api key") are non-transient and must surface immediately even with ``retry_on_error=True``. - ``client.py``: forward ``self.retry_on_error`` from ``create_api_key`` and ``derive_api_key`` into the auth call. Clients constructed with ``ClobClient(retry_on_error=True)`` get the same retry behaviour they had before PR Polymarket#42. Tests ----- ``TestAuthRetryOnErrorParity`` (4 new tests): - ``test_auth_post_retries_on_500_when_retry_on_error_true`` - ``test_auth_post_does_not_retry_when_retry_on_error_false`` - ``test_auth_post_does_not_retry_on_400`` (401 is non-transient) - ``test_auth_get_forwards_retry_on_error_kwarg`` Test results: 36/36 in ``test_user_agent_header.py`` (was 32), 168 SDK regression tests pass (was 164), zero regressions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bugbot 2nd-review fix (commit
|
| Commit | Findings | Fixed |
|---|---|---|
ba44d13 |
4 (PEP 604 / proxies kwarg / dead-code / Brotli) | 31134da |
31134da |
1 (retry parity) | 7d78df5 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7d78df5. Configure here.
…78df5) Cursor Bugbot's 3rd review on `7d78df5` flagged that the regression test ``test_get_does_not_advertise_brotli_without_dep`` would silently fail to guard against the bug it was written to prevent. Bug --- ``"gzip, deflate, br".split(",")`` produces ``["gzip", " deflate", " br"]`` (leading whitespace). The naïve ``"br" not in split(",")`` membership check passes because Python compares ``"br"`` to ``" br"`` literally — different strings. If a future maintainer re-introduces ``"gzip, deflate, br"``, the test would assert ``not in`` against ``[" br"]`` and PASS, allowing the Brotli-without-dep regression through. Fix --- Strip whitespace from each token AND drop any q-value parameters (``"br;q=0.5"`` is the same regression class). Membership check runs against the cleaned token list. Also keep the original ``deflate`` sanity assertion against the cleaned list so the test fails noisily on any whitespace drift. Tests ----- 36/36 ``test_user_agent_header.py`` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bugbot 3rd-review fix (commit
|
| Commit | Findings | Fixed by |
|---|---|---|
ba44d13 |
4 | 31134da |
31134da |
1 (auth retry parity) | 7d78df5 |
7d78df5 |
1 (test regression-strip) | c9f0b4d |

fix(auth): UA + browser-headers + opt-in residential proxy to bypass Cloudflare 403 (closes #41)
Problem
POST /auth/api-keyreturns HTTP 403 from Cloudflare when using the defaultpython-httpx/X.YUser-Agent string (reported in #38, #41). The previouspartial fix in
_overload_headerssetUser-Agent: py_clob_client_v2— abare, unversioned string that Cloudflare's bot-score heuristic still treats as
suspicious, leaving the 403 unresolved for most users.
Three independent reporters then confirmed that upgrading the UA alone
does NOT bypass the WAF:
copperhuh(Cloudflare blocks API key creation (403) with py_clob_client_v2 #41): "I ran with the UA fix; still 403."Zero-Ace5(Cloudflare blocks API key creation (403) with py_clob_client_v2 #41): "Tried JS variant + same UA; backend still rejects."calrde(this PR review): "Env-var workaround tested — does not unblock."Cloudflare's bot detection scores on multiple signals beyond UA: TLS
fingerprint (JA3/JA4), HTTP/2 frame settings, header order, and IP
reputation. A datacenter IP + a clean UA still scores high enough to
trip the 403; the only confirmed-working bypass in similar deployments
(yfinance, ccxt-pro) is residential rotating proxy + full browser
header bundle (cf. scrapfly.io 2026 Cloudflare bypass guide;
IPRoyal residential pool).
Fix (three layers)
Layer 1 — Versioned User-Agent (shipped in commit 6fedd7f)
constants.py:__version__resolved at import time viaimportlib.metadata.versionwith a hard-coded fallback ("1.0.1rc1").constants.py:DEFAULT_USER_AGENT = f"polymarket-clob-client-v2/{__version__}"helpers.py:_resolve_user_agent()returnsos.environ.get("POLY_USER_AGENT", DEFAULT_USER_AGENT).Layer 2 — Full browser-header bundle (this commit)
A real browser sends ~10 headers with every navigator-driven fetch.
Sending only
User-Agent+Accept: */*is a strong bot signal evenwith a plausible UA.
_overload_headersnow sets:Accept: application/json, text/plain, */*(Chrome's XHR default)Accept-Language: en-US,en;q=0.9Accept-Encoding: gzip, deflate, br(Chrome's full set; GET only)sec-ch-ua: "Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"sec-ch-ua-mobile: ?0sec-ch-ua-platform: "macOS"sec-fetch-dest: emptysec-fetch-mode: corssec-fetch-site: same-siteAll bundle values are set via
dict.setdefault(...)so caller-suppliedheaders still win.
Layer 3 — Opt-in residential proxy for the auth call (this commit)
helpers.py:get_auth_proxy_url()readsPOLY_AUTH_PROXYenv var.helpers.py:build_auth_http_client(timeout_s)returns anhttpx.Clientconfigured withproxies={"https://": url, "http://": url}when the env var is set; otherwise a direct client.
The proxy is wired into the L1
/auth/api-keycall ONLY, NOT theorder or market-data path. Rationale:
the residential-proxy latency cost (typically 100-400 ms) is paid once
per L2 refresh, not per request.
latency-sensitivity; routing it through a residential proxy would add
300+ ms to every taker fill.
Tested provider: IPRoyal residential rotating
pool ($7/GB at the time of writing). BrightData and Smartproxy expose
the same
https://user:pass@host:portURL shape and should workidentically.
Backwards compatibility
POLY_AUTH_PROXYget the existing directpath (UA + browser headers only).
POLY_USER_AGENTget the new versioned UAautomatically on upgrade.
_overload_headersusessetdefault(...)for the bundle, so anycaller-supplied header (e.g. a test that asserts a specific Accept
value) still overrides the default.
Configuration
Testing
tests/test_user_agent_header.pynow covers 23 cases:DEFAULT_USER_AGENTshape_resolve_user_agentenv override_overload_headersUA + Accept-Encoding contractsPOLY_AUTH_PROXYhonoured onbuild_auth_http_client(NEW)All tests pass on Python 3.9–3.14 with only stdlib + httpx installed.
Acknowledgements
Thanks to
copperhuh,Zero-Ace5,lakeswimmer, andcalrdefor thecommunity evidence that UA-only is insufficient — without your repros
this PR would have shipped a partial fix.
Checklist
POLY_AUTH_PROXY(this commit)Note
Medium Risk
Changes the L1 authentication request path (
/auth/api-key) to use a new proxy-capable client and different default headers, which could affect auth reliability and behavior across httpx versions/environments.Overview
Improves Cloudflare WAF avoidance for L1 auth by expanding default outbound headers to a browser-like bundle (via
_overload_headers) and introducing an opt-in residential proxy for auth requests throughPOLY_AUTH_PROXY.Routes
ClobClient.create_api_key/derive_api_keythrough newauth_post/auth_gethelpers that build a per-callhttpx.Client(proxy-aware) and preserveretry_on_errorparity, while keeping all non-auth traffic on the existing shared client.Adds versioned
DEFAULT_USER_AGENT(withPOLY_USER_AGENToverride) and a comprehensive new regression test suite covering UA/header injection, Accept-Encoding behavior, proxy wiring, and Python 3.9 compatibility.Reviewed by Cursor Bugbot for commit c9f0b4d. Bugbot is set up for automated code reviews on this repo. Configure here.