Fixed 4 min read

GitHub kept returning 403. I blamed the token for ten days; the wall was our own runner.

A 403 looked like a rate limit, so I spent ten days fixing the GitHub token. Only after I logged the response body did I see the wall was not GitHub but the cloud environment the pipeline runs in.

This is not a news summary. It is a record of something that happened while building this site.

An unattended collector gathers tool releases every morning. It scans what ChatGPT, Claude, Codex and the rest have shipped and pins it next to that day's briefing. Codex had returned nothing since August 25. GitHub was answering with 403.

A 403 usually means too many requests. So I spent ten days wrestling with the GitHub token. The token was never the problem.

I thought I had hit a rate limit

Calling the GitHub API without authentication gives you 60 requests an hour per IP. An unattended environment shares one IP across many jobs, so that ceiling runs out fast. A token raises it to 5,000. So I issued a token and put it in the environment.

The next day it was still 403.

Changing the token did not help

First I doubted the token was even reaching the run. Environment variables not loading in unattended runs had bitten me before. So I made the run record whether a token was present, just present or absent and nothing more. The next day's record said present.

So was the token invalid? No. An invalid token gets a 401, not a 403. A 403 meant GitHub had accepted the token at least that far.

Maybe it was scoped too narrowly. So I reissued one that can definitely read public repositories and swapped it in. Still 403.

A request with no User-Agent can also draw a 403 from GitHub. I opened the code. The User-Agent and the auth header were both being attached correctly.

What I suspectedWhy it was not that
The token was not loadedThe record showed the token present
The token was invalidAn invalid token gets a 401, not a 403
The token was too narrowA public-read token still got 403
No User-Agent was sentThe code was attaching one correctly

I had crossed off each suspicion and the 403 held. What was left was the one place I had not looked: what the failing request actually received.

The status code was the same, the body was not

Until then the code kept only the number 403 and threw away the body GitHub sent with it. GitHub writes the reason it refused you into that body. I changed one line to keep the body in the record.

The next run gave me the answer.

{"message": "GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App for this organization."}

It was not a rate limit. The request never reached GitHub's servers. The cloud environment this pipeline runs in was intercepting requests bound for GitHub. That environment opens GitHub access only to the repository tied to the session, and Codex is someone else's repository, so it was blocked.

The token had no power in front of this wall. The request was stopped before it reached GitHub, so any token gave the same answer. Ten days of fixing the token was changing keys at a door that was already shut.

I tried binding the Codex repository to the session as well. It cloned, but the API calls were still blocked. Opening access is a separate matter that needs an org admin to connect the GitHub App, and for someone else's public repository that was murky too.

Stepping outside the gateway

Only requests to GitHub's own domains were intercepted. Our own domain is left alone. So I put a small function on our site. Running outside the gateway, it fetches the GitHub releases and hands them straight back. The collector calls our address instead of GitHub.

What comes back is GitHub's original payload, so the reading code needed no changes. An unattended run confirmed it: Codex was back, five items from the release I had been missing for ten days.

What I am taking from it

This repo has a sibling note. A dead feed dressed a failure as a success code and it took two weeks to catch. That note ends like this: "This one came back 202. The next one might come back 200. So the check reads the body, not just the code."

This one came back 403. And that 403 was not one character different from the 403 of a real rate limit. What separated them was one line of the body. A status code tells you what was blocked; who blocked it and why is in the body. The ten days were the price of not reading it.

← All notes

How this site picks what it picks is written up in About, and the daily intake and publication figures are on the Data page.