Cloudflare edge 403 on *.workers.dev calls from Python
Symptom: a Python script calling a p24 Cloudflare Worker (p24-meta-dispatcher,
p24-auth-worker, monitoring-watchdog, p24-tg-notify-proxy, …) fails with:
HTTP Error 403: Forbidden
…while the exact same request from curl — same URL, method, body and API key — succeeds.
First seen: #4292 (2026-07-19) — daily-session-audit.yml created audit issue #4279 fine but
every /queue-issue dispatch was rejected, so the DSA worker was never spawned. The daily cron had
been failing at this step and the failed-jobs audit classified the root cause as unknown.
Root cause
The 403 is not from the Worker. It is emitted by Cloudflare’s edge (WAF / bot protection) before the request ever reaches Worker code.
urllib.request sends User-Agent: Python-urllib/3.x by default, and the zone’s managed bot rules
block that UA outright.
This is easy to misdiagnose as a bad QUEUE_API_KEY, because 403 looks like an auth failure.
It is not — the key is never evaluated.
Diagnosis — 30 seconds, no credentials needed
Our Workers’ auth guard returns 401 for a missing/bad key, and never 403. So the status code alone tells you which layer rejected you. Probe unauthenticated and vary only the UA:
URL="https://p24-meta-dispatcher.radieu.workers.dev"
# curl's own UA
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$URL/queue-issue" \
-H "Content-Type: application/json" -d '{"issue_number":1}'
# → 401 = request reached the Worker
# the Python default UA
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$URL/queue-issue" \
-A "Python-urllib/3.12" -H "Content-Type: application/json" -d '{"issue_number":1}'
# → 403 = blocked at the edge, Worker never ran| Status | Rejected by | Meaning |
|---|---|---|
401 | Worker (verifyApiKey) | Edge passed. Key missing/wrong — a genuine credential problem. |
403 | Cloudflare edge | Worker never ran. Almost always the User-Agent. Key is irrelevant. |
200 / 201 | Worker | Success (200 = already in flight, 201 = queued). |
Never rotate QUEUE_API_KEY in response to a 403 — it will not help.
Fix
Send an explicit, non-default User-Agent on every outbound request:
USER_AGENT = "p24-infra-<component>/1.0 (+https://github.com/radieu/p24-infra)"
req = urllib.request.Request(
f"{QUEUE_API_URL}/queue-issue",
data=payload,
headers={
"Authorization": f"Bearer {QUEUE_API_KEY}",
"Content-Type": "application/json",
"User-Agent": USER_AGENT, # ← required; the default UA is blocked
},
method="POST",
)requests and httpx set their own non-blocked UA, so they do not hit this — but set an explicit
one anyway so the caller is identifiable in Cloudflare’s request logs.
Also log the response body
A bare HTTP Error 403: Forbidden is what made #4292 land in the audit as unknown. Always read
the body out of HTTPError — it is the only way to tell an edge block from a Worker rejection
after the fact:
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")[:300].strip()
raise RuntimeError(f"Queue API HTTP {e.code}: {detail or e.reason}") from eChecklist for any new Python caller of a Worker endpoint
- Explicit
User-Agentheader set -
HTTPErrorhandler reads and reportse.codeand the body - Timeout passed to
urlopen(a hung edge connection otherwise stalls the cron) - Regression test asserting the UA header — see
scripts/tests/test_dsa_create_audit_issues.py
Known callers (audited 2026-07-19)
| File | Status |
|---|---|
scripts/dsa/create-audit-issues.py | Fixed — #4292 |
scripts/agent-push-error.py | Fixed — #4292 (was failing silently, return False) |
scripts/alert-ingest.py | N/A — targets Supabase REST + Discord + GitHub API, not a Worker |
Related
docs/playbooks/email-api-waf-ip-allowlist-403.md— the look-alikeYour request was blocked.hostname-wide IP-allowlist WAF block (different body, different fix — check the body text first)docs/playbooks/gh-actions-failed-jobs-taxonomy.mdinfra-src/meta-dispatcher/src/index.ts—verifyApiKey, the 401-not-403 guard