cd /news/developer-tools/a-missing-id-doesn-t-404-it-fetches-… · home topics developer-tools article
[ARTICLE · art-101256] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer discovered a bug in the REST API of AI Change Watch, a project tracking AI model changes, where a missing ID in a URL path silently degrades to the collection endpoint, returning a 200 OK with an array instead of a 404. This caused pages like /event/google- to return 500 errors, and the issue was invisible in request logs because the worker outcome was 'ok'. The developer fixed it by adding guards in the data layer to treat empty IDs and non-DTO responses as not-found.

read5 min views1 publishedAug 18, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @ai change watch 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-missing-id-doesn-t…] indexed:0 read:5min 2026-08-18 ·