headroomlabs-ai/headroom · error · OAuth2Error
token endpoint returned HTTP {e.code}
Error message
token endpoint returned HTTP {e.code} What it means
The OAuth2 provider's token request (stdlib urllib POST) received an HTTP error status from the IdP; it is re-raised as OAuth2Error with just the status code. The response body is deliberately drained and NOT surfaced, because IdP error bodies may echo sensitive context (client_id, redirect hints). Common codes: 400 invalid_client/scope, 401 bad credentials, 404 wrong URL path.
Source
Thrown at plugins/headroom-oauth2/src/headroom_oauth2/provider.py:134
if self.auth_style == "basic":
creds = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode()
headers["Authorization"] = "Basic " + creds
else:
form["client_id"] = self.client_id
form["client_secret"] = self.client_secret
req = urllib.request.Request(
self.token_url,
data=urllib.parse.urlencode(form).encode(),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
payload = json.load(resp)
except HTTPError as e:
with suppress(Exception):
e.read() # drain; do NOT surface the IdP body (may echo sensitive context)
raise OAuth2Error(f"token endpoint returned HTTP {e.code}") from None
except (URLError, OSError) as e:
raise OAuth2Error(f"token endpoint unreachable: {e}") from None
except json.JSONDecodeError:
raise OAuth2Error("token endpoint returned non-JSON") from None
token = payload.get("access_token")
if not token:
raise OAuth2Error("token endpoint response had no access_token")
raw = payload.get("expires_in")
try:
ttl = int(float(raw)) # tolerate "3600", "3600.0", 3600, or a JSON float
except (TypeError, ValueError):
ttl = 300
ttl = max(1, ttl) # 0/negative would cause a stale token or per-request minting
log.info("oauth2: minted token (ttl=%ss, scopes=%s)", ttl, self.scopes or "-")
return token, ttl
View on GitHub (pinned to 322425c43b)
Solutions
- Check the status: 401/400 → verify client_id/client_secret and scopes against the IdP app registration; 404 → re-check the token_url path and tenant
- Re-issue and redeploy the client secret if credentials were rotated or expired
- Reproduce outside the app with curl (safely, in a dev tenant) to see the IdP's error body, which the library intentionally hides: `curl -d grant_type=client_credentials -d client_id=... -d client_secret=... -d scope=... $TOKEN_URL`
Example fix
# before
HEADROOM_OAUTH2_CLIENT_SECRET=old-rotated-secret
# after
HEADROOM_OAUTH2_CLIENT_SECRET=newly-issued-secret
# verify shape first:
# curl -s -o /dev/null -w '%{http_code}' -d 'grant_type=client_credentials' \
# -d 'client_id=$ID' -d 'client_secret=$SECRET' -d 'scope=$SCOPE' $TOKEN_URL -> want 200 Defensive patterns
Strategy: retry
Validate before calling
# cheap pre-flight: credentials non-empty and scope names confirmed against app registration
assert client_id and client_secret, "empty oauth2 credentials will yield HTTP 400/401"
# optional dev-tenant probe (never log the secret):
# curl -o /dev/null -w '%{http_code}' -d grant_type=client_credentials -d client_id=$ID -d client_secret=$SEC -d scope=$SCOPE $TOKEN_URL == 200 Try / catch
from headroom_oauth2.provider import OAuth2Error
for attempt in range(3):
try:
token = provider.get_token()
break
except OAuth2Error as e:
if "HTTP 401" in str(e) or "HTTP 400" in str(e):
raise RuntimeError(f"credentials/scopes rejected by IdP: {e}") from e # not retryable
if attempt == 2:
raise
time.sleep(2 ** attempt) # 5xx / transient only Prevention
- Treat 4xx as config bugs (fail fast) and 5xx as transient (retry with backoff)
- Automate secret rotation so deployed credentials are never the revoked ones
- Keep a curl repro command in runbooks to see the IdP error body the library hides
When it happens
Trigger: Minting a token with wrong client_id/client_secret (401), requesting an unsupported scope or audience (400), a token URL with a wrong path (404), or client credentials that were rotated and revoked (400 invalid_client).
Common situations: Secret rotation where the deployed credential was revoked; typo'd scope names like `api://my-app/.default` mangled by templating; copying a token URL from docs but with the wrong tenant or missing `/token` suffix; clock/env drift between environments sharing one app registration.
Related errors
- upstream request failed: {0}
- Copilot token exchange failed with HTTP {exc.code}: {body}
- Not Found
- token endpoint unreachable: {e}
- token endpoint returned non-JSON
AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15).
Data as JSON: /api/errors/7b90893e923c1249.
Report an issue: GitHub.