I run AI Change Watch, a small independent project that
Keeping it running turned up a bug I think a lot of REST clients have and nobody notices, because it
doesn't look like a bug from either side.
Here it is in one line:
GET /v1/events/{id} with an empty id
becomes
GET /v1/events/
which is not a 404. It's the list endpoint. It returns 200 OK
.
Every change on my site lives at a URL like:
/event/openai-gpt-4-deprecated-bf_20260723143458_0003
Slug for humans, id after the last hyphen for the lookup. The parser is exactly what you'd expect:
export function eventIdFromParam(param: string): string {
const i = param.lastIndexOf('-');
return i === -1 ? param : param.slice(i + 1);
}
Now consider /event/google-
. A trailing hyphen, no id. Crawlers generate these. So do chat clients and
mail readers that break a long URL across lines and leave the tail behind.
lastIndexOf('-')
finds the final character, slice(i + 1)
returns ""
, and the fetch goes out as
/v1/events/
.
The API answers 200
with { data: [ …every recent event… ] }
.
My mapper then did what mappers do — it mapped:
if (j) return fromApi(j.data); // j.data is an ARRAY here
fromApi
read .title
, .providerName
, .severity
off an array. All undefined
. No error yet:
reading a missing property off an array is perfectly legal. The page got an object shaped like an event
whose every field was empty, rendered happily down the tree until it reached:
providerName.charAt(0)
and threw.
So the URL returned 500 where a 404 was owed. /event/google-
, /event/aws-
,
/ja/event/groq-groq-
— all 500, live, for as long as they had existed.
Two reasons, and the second is the interesting one.
It is invisible in the request logs. I found this by querying Cloudflare's observability API for
5xx responses, and the field I would naturally have filtered on was useless:
$workers.outcome = "ok" ← for every single one of them
A rendered 500 is a successful worker invocation. The worker ran, produced a response, returned it.
That the response was an error page is not the worker's problem. The signal lives in
$metadata.error
, not in the outcome. If you filter your edge logs by outcome, application-level 500s
are simply not in your dataset.
And a sibling route accidentally hid it. The same data layer serves
/pricing/history/<slug>-<id>
, and that route never 500'd. Not because it was written more carefully —
because it happens to filter:
type === 'pricing_changed'
The junk object had type: undefined
, so the filter rejected it and the page 404'd correctly.
Entirely by accident.
That made the bug look route-specific. I spent time reading the event page, which was the one place
the defect wasn't.
It isn't the parser, and it isn't the page. It's this:
A REST detail path with a missing key silently degrades into the collection path.
/things/{id}
and /things/
are different endpoints with different response shapes, and the only
thing separating them is a string you built by hand. When that string is empty, the URL you send is a
valid request for something else entirely, and it succeeds.
No status code tells you. Both are 200
. Both return { data: … }
. The only difference is that one
data
is an object and the other is an array — and JavaScript will let you read properties off both.
Two guards, and it matters that they are in the data layer rather than in the page:
export async function getEvent(id: string): Promise<CWEvent | null> {
// An empty id is a not-found, not a request.
if (!id) return null;
const j = await api<{ data: any }>(`/v1/events/${id}`);
// Only a DTO that actually carries an id is an event. Anything else — a list payload,
// `{data:null}` — is not-found, never a half-populated object handed to the renderer.
if (j?.data?.id) return fromApi(j.data);
if (j) return null;
// `j === null` means the API was never reached at all (build time, or local dev with no base URL),
// which is a different condition from "the API answered and there is no such event".
return MOCK_EVENTS.find((e) => e.id === id) ?? null;
}
The first guard stops the malformed request being sent. The second stops a wrong-shaped response being
trusted if one arrives anyway. The third line matters for a reason worth stating: "the API said no" and "I never reached the API" have to stay distinguishable, or a build-time render quietly turns
Putting all this in getEvent()
rather than in the page component covers three call sites at once: the
page, generateMetadata
, and the OpenGraph image route. Fixing it in the component would have left two
of those still 500ing, and OG image failures are especially quiet — nobody notices a missing preview
card until someone shares the link.
All four URLs are 404s now:
/event/google- 404
/event/aws- 404
/ja/event/groq-groq- 404
/pricing/history/deepseek- 404
The grep that would have found it for me:
grep -rnE '`[^`]*/\$\{[A-Za-z_]+\}`' src/
Then, for each hit, three questions:
split()
or a slice()
can be. Mine came from lastIndexOf
.200
with a different shape, you
have this bug waiting. If it 404
s or 405
s, you don't. This is worth one curl:
curl -i https://api.example.com/v1/things/
.id
off an array
returns undefined
rather than throwing, so the failure surfaces far away from its cause — in my
case several components later, on a .charAt(0)
.If you own the API as well as the client, there is a fourth option that fixes it for every consumer at
once: make the collection path reject a trailing slash instead of serving the list. I didn't, because
the list endpoint is a real endpoint that real callers use — but if yours isn't, that's the cheaper fix.
The one-line version: check the id before you build the URL, and check the response carries an id before you trust it. Neither check is clever. Both were missing.
Found 2026-08-08, fixed the same day. The tracker this came out of is at aichangewatch.com — it watches AI vendor docs for changes, which is how it ends up with a lot of URLs that crawlers like to truncate.