DEV Community

ushiro
ushiro

Posted on

A Missing ID Doesn't 404 — It Fetches the Whole Collection

I run AI Change Watch, a small independent project that
crawls what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing
and SDK releases — and records every time one of them changes.

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
Enter fullscreen mode Exit fullscreen mode

becomes

GET /v1/events/
Enter fullscreen mode Exit fullscreen mode

which is not a 404. It's the list endpoint. It returns 200 OK.

What that did to my site

Every change on my site lives at a URL like:

/event/openai-gpt-4-deprecated-bf_20260723143458_0003
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Why it survived so long

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.

The actual shape of the problem

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.

The fix, and where it goes

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;
}
Enter fullscreen mode Exit fullscreen mode

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
every page into a 404.

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
Enter fullscreen mode Exit fullscreen mode

How to find this in your own code

The grep that would have found it for me:

# a template literal that interpolates straight into a path segment
grep -rnE '`[^`]*/\$\{[A-Za-z_]+\}`' src/
Enter fullscreen mode Exit fullscreen mode

Then, for each hit, three questions:

  1. Can that value ever be an empty string? Anything derived from a URL segment, a regex capture, a split() or a slice() can be. Mine came from lastIndexOf.
  2. What does your API return for the collection path? If it is a 200 with a different shape, you have this bug waiting. If it 404s or 405s, you don't. This is worth one curl: curl -i https://api.example.com/v1/things/
  3. Does your mapper verify the shape, or just read fields off it? Reading .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.

Top comments (0)