DEV Community

freeourdays
freeourdays

Posted on

Our own CDN was 403-ing the official OpenAI SDK, and our test suite couldn't see it

We wrote a bot-blocking rule at the CDN edge. It matched the official OpenAI and
Anthropic SDKs by the shape of their User-Agent string and returned 403 to all of
them. It ran that way for a while, and our compatibility test suite reported green
the entire time.

The suite was green because the probe sent its own User-Agent, and its own
User-Agent was on the allowlist. That is the part worth your time. The outage is
ordinary; the blind spot is structural, and it is sitting in most home-grown test
tooling I have seen.

Three mistakes below, all ours. Numbers are from the retest on 2026-08-31.

What the failure actually looked like

The rule was aimed at AI crawlers. It matched on a Vendor/Language version
User-Agent pattern, which is exactly what the official SDKs send by default:

  • OpenAI/Python 1.68.2
  • OpenAI/JS 4.104.0
  • Anthropic/JS 0.120.0

The damage split into two layers, which is why it stayed invisible for so long:

  1. Humans were fine. Someone clicking a link from an external site got a normal page. Browser traffic never matched the pattern.
  2. Developers were not. Anyone who installed the official SDK and pointed it at our endpoint got 403 Your request was blocked. before their request reached the application at all.
  3. AI engines were not either. The same rule blocked crawler User-Agents, so the llms.txt assets we had written specifically for them were unreachable.

The 403 is worse than a plain outage, because of what it looks like from the other
side. A blocked request returns 403, not 401. A developer whose first-ever request
to your API returns 403 concludes that they pasted the key wrong. They will not file
a bug. They will close the tab.

The mistake that cost the most: our probe was on the allowlist

We do not install nine GUI clients and click through them. We reproduce the
characteristic HTTP request each client sends, fire it at the real user-facing
endpoint, and check the response field by field. It is repeatable, and repeatable
matters when the upstream model catalog changes under you.

That suite was green. It tested protocol shape: streaming frames, tool-call
assembly, response_format, the Anthropic event sequence. Every one of those
answers the question does the server understand this request. Not one of them
answers does this request get in.

We found it by accident, while double-checking a streaming result with the official
Anthropic SDK. That call returned 403 Your request was blocked. The identical
request sent with curl returned 200. The only difference between the two was the
User-Agent header.

Our probe's UA was in the allowed set. So the suite was structurally incapable of
seeing a rule that keyed on UA. It was not a missing test case; it was a test client
whose identity happened to be the safe one.

Reproduce it against your own endpoint

Two requests, same body, same path, same key. Only the User-Agent differs. If the
statuses disagree, something in front of your application is filtering on UA.

# 1. Baseline. curl's default UA is not an SDK shape.
curl -sS -o /dev/null -w '%{http_code}\n' \
  -X POST "$BASE_URL/chat/completions" \
  -H "authorization: Bearer $API_KEY" \
  -H 'content-type: application/json' \
  -d '{"model":"'"$MODEL"'","messages":[{"role":"user","content":"hi"}],"max_tokens":4}'

# 2. Same request, wearing the official SDK's default UA.
curl -sS -o /dev/null -w '%{http_code}\n' \
  -X POST "$BASE_URL/chat/completions" \
  -H "authorization: Bearer $API_KEY" \
  -H 'content-type: application/json' \
  -H 'user-agent: OpenAI/Python 1.68.2' \
  -d '{"model":"'"$MODEL"'","messages":[{"role":"user","content":"hi"}],"max_tokens":4}'
Enter fullscreen mode Exit fullscreen mode

Then confirm with the real SDK, because a hand-written header is still your guess at
what the SDK sends:

from openai import OpenAI

client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
print(client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content": "hi"}],
    max_tokens=4,
).choices[0].message.content)
Enter fullscreen mode Exit fullscreen mode

Do the same for crawlers, against a static asset rather than the API:

curl -sS -o /dev/null -w '%{http_code}\n' \
  -H 'user-agent: Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.2; +https://openai.com/gptbot' \
  https://your-site.example/llms.txt
Enter fullscreen mode Exit fullscreen mode

If you want a live target to check the harness against before pointing it at your own
host, our /llms.txt answers 200 to all
five crawler UAs as of the retest date — that is the same URL the numbers above were
measured on, so a 403 there means the regression is back on our side.

Two details that make the check reliable. An edge block has a signature worth
matching on rather than eyeballing: status 403, plus a server: cloudflare header,
plus a very short text/plain body — we treat under 200 bytes as the cutoff.
And run the unauthenticated version too: with no credentials, a healthy endpoint
answers 401 Invalid token. If it answers 403 instead, you never reached the
application.

What the fix was, and where it was not

The fix was in the Cloudflare rule. No application code changed. That is worth
stating plainly, because for the entire time this was broken, every line of our
request-handling code was correct and every protocol probe agreed.

The retest on 2026-08-31:

  • 7 of 7 SDK User-Agents allowed through.
  • 5 of 5 AI crawler User-Agents allowed through on llms.txt.
  • The client matrix went from 6 clients unusable to 8 of 9 usable.

The 6 were the ones that send an SDK-shaped UA: Cline, Roo Code, LibreChat,
Continue, Claude Code, and the official OpenAI SDK. Aider and Open WebUI were never
blocked, because neither sends that shape — Aider goes through LiteLLM
(litellm/1.63.0, lowercase, hyphenated) and Open WebUI uses aiohttp directly. Two
clients passing by accident is not coverage.

The 9th row is still not usable, and not for reachability reasons. It is the official
OpenAI SDK, which we keep as a strict baseline: it requires vision and a
well-formed error shape, and we fail both. Our vision column is worse than the
reachability story suggests — we sent five upstream models a solid green PNG and
asked what color it was, and all five got it wrong, so we do not claim vision
support. Unknown model names return 503 instead of 4xx, which makes SDKs retry a
typo as if it were a server fault. Those are open, not fixed.

We also added the missing probes: a reachability gate that replays each client's
real UA, run before anything else. When that gate is red the protocol results below
it are meaningless, so it now runs first.

Mistake two: the probe pinned the model name from our own docs

The suite's primary model was hard-coded to the model ID in our documentation
examples. When the upstream catalog dropped that ID, the probe threw on startup and
all 19 checks refused to run. A recoverable drift became a hard block, because a
single model name was wired up as the master switch.

The catalog gets reshuffled routinely and there is always some working model, so
this was the wrong failure mode. It now picks dynamically:

  1. Try the documented example model first. This ordering is deliberate: only if the suite passes on the model in the docs does "our tests pass" mean "a user pasting from our docs succeeds." One model ID is shared by the docs page, the homepage snippet, and llms-full.txt, so when it stops being callable, every copy-paste path breaks at step one and no page on the site looks any different.
  2. If that one will not answer, fall back to the first callable model in the catalog and keep going.
  3. Report "is the documented example model callable" as its own separate probe, demoted from a blocker to a finding.

That third point is the actual repair. The check was worth keeping; it just should
never have been able to stop everything else from running.

Mistake three: the generated doc published a lie during an upstream slowdown

This one took the longest to notice, and it is the one I would most expect other
people to have shipped.

The compatibility matrix is generated. The script overwrites the file on every run,
and that file is the single source of truth for everything we say publicly about
what we support.

We ran it once while the upstream was degraded. A non-streaming request took 89
seconds against a 120-second timeout — slow enough to be useless, fast enough to not
time out. Requests that did cross 120 seconds, plus some intermittent 404s, were
recorded as genuine failures. The generator did what it was told and wrote a
document marking all nine clients unusable, including Aider and Open WebUI,
which were working the whole time.

For as long as that file stood, our own documentation claimed we supported nothing.
Nobody had to be wrong for this to happen; the script was correct and the output was
false.

The fix is a health gate. The script now measures the primary model's latency, and
past a 30-second budget it declares the run degraded, refuses to write the file, and
prints results to stdout for a human to read. A stale document beats a confidently
wrong one, because a stale one is at least a claim somebody once verified.

If you generate anything user-facing from live measurements, this applies to you
directly: a generator with write access and no health gate will eventually publish
your worst five minutes as your steady state.

The generalization

Reproducing a client's request shape is a good technique and I would use it again.
It is repeatable, it is cheap, and it catches real protocol breakage.

But it tests the request. It does not test the requester. Everything that gets
decided by who is asking — UA rules, bot detection, WAF heuristics, rate-limit
buckets, IP reputation — is invisible to a probe that shows up wearing its own
identity. And a home-grown probe always shows up wearing an identity you chose,
which means it is almost always in the group you decided to let through. That is not
a gap you can close by adding assertions. You close it by sending the request as the
client, with the client's real User-Agent, or by installing the actual SDK and
letting it speak for itself.

The question to ask about your own test suite is not "what does it check." It is
"which class of client is it, and who else is in that class." Ours was in the class
we had allowlisted, so the 403 was never something it could have reported.

Top comments (0)