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

> Source: <https://dev.to/ai_changewatch/a-missing-id-doesnt-404-it-fetches-the-whole-collection-3ii2>
> Published: 2026-08-18 12:00:00+00:00

I run [ AI Change Watch](https://aichangewatch.com), 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:

``` js
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:

```
# a template literal that interpolates straight into a path segment
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.*
