{"slug": "a-missing-id-doesn-t-404-it-fetches-the-whole-collection", "title": "A Missing ID Doesn't 404 — It Fetches the Whole Collection", "summary": "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.", "body_md": "I run [ AI Change Watch](https://aichangewatch.com), a small independent project that\n\nKeeping it running turned up a bug I think a lot of REST clients have and nobody notices, because it\n\ndoesn't look like a bug from either side.\n\nHere it is in one line:\n\n```\nGET /v1/events/{id}   with an empty id\n```\n\nbecomes\n\n```\nGET /v1/events/\n```\n\nwhich is not a 404. It's the **list** endpoint. It returns `200 OK`\n\n.\n\nEvery change on my site lives at a URL like:\n\n```\n/event/openai-gpt-4-deprecated-bf_20260723143458_0003\n```\n\nSlug for humans, id after the last hyphen for the lookup. The parser is exactly what you'd expect:\n\n``` js\nexport function eventIdFromParam(param: string): string {\n  const i = param.lastIndexOf('-');\n  return i === -1 ? param : param.slice(i + 1);\n}\n```\n\nNow consider `/event/google-`\n\n. A trailing hyphen, no id. Crawlers generate these. So do chat clients and\n\nmail readers that break a long URL across lines and leave the tail behind.\n\n`lastIndexOf('-')`\n\nfinds the final character, `slice(i + 1)`\n\nreturns `\"\"`\n\n, and the fetch goes out as\n\n`/v1/events/`\n\n.\n\nThe API answers `200`\n\nwith `{ data: [ …every recent event… ] }`\n\n.\n\nMy mapper then did what mappers do — it mapped:\n\n```\nif (j) return fromApi(j.data);   // j.data is an ARRAY here\n```\n\n`fromApi`\n\nread `.title`\n\n, `.providerName`\n\n, `.severity`\n\noff an array. All `undefined`\n\n. **No error yet:**\n\nreading a missing property off an array is perfectly legal. The page got an object shaped like an event\n\nwhose every field was empty, rendered happily down the tree until it reached:\n\n```\nproviderName.charAt(0)\n```\n\nand threw.\n\nSo the URL returned **500 where a 404 was owed**. `/event/google-`\n\n, `/event/aws-`\n\n,\n\n`/ja/event/groq-groq-`\n\n— all 500, live, for as long as they had existed.\n\nTwo reasons, and the second is the interesting one.\n\n**It is invisible in the request logs.** I found this by querying Cloudflare's observability API for\n\n5xx responses, and the field I would naturally have filtered on was useless:\n\n```\n$workers.outcome = \"ok\"     ← for every single one of them\n```\n\nA rendered 500 is a *successful* worker invocation. The worker ran, produced a response, returned it.\n\nThat the response was an error page is not the worker's problem. The signal lives in\n\n`$metadata.error`\n\n, not in the outcome. If you filter your edge logs by outcome, application-level 500s\n\nare simply not in your dataset.\n\n**And a sibling route accidentally hid it.** The same data layer serves\n\n`/pricing/history/<slug>-<id>`\n\n, and that route never 500'd. Not because it was written more carefully —\n\nbecause it happens to filter:\n\n```\ntype === 'pricing_changed'\n```\n\nThe junk object had `type: undefined`\n\n, so the filter rejected it and the page 404'd correctly.\n\nEntirely by accident.\n\nThat made the bug look route-specific. I spent time reading the event page, which was the one place\n\nthe defect *wasn't*.\n\nIt isn't the parser, and it isn't the page. It's this:\n\nA REST detail path with a missing key silently degrades into the collection path.\n\n`/things/{id}`\n\nand `/things/`\n\nare different endpoints with different response shapes, and the only\n\nthing separating them is a string you built by hand. When that string is empty, the URL you send is a\n\n*valid request for something else entirely*, and it succeeds.\n\nNo status code tells you. Both are `200`\n\n. Both return `{ data: … }`\n\n. The only difference is that one\n\n`data`\n\nis an object and the other is an array — and JavaScript will let you read properties off both.\n\nTwo guards, and it matters that they are in the data layer rather than in the page:\n\n```\nexport async function getEvent(id: string): Promise<CWEvent | null> {\n  // An empty id is a not-found, not a request.\n  if (!id) return null;\n\n  const j = await api<{ data: any }>(`/v1/events/${id}`);\n\n  // Only a DTO that actually carries an id is an event. Anything else — a list payload,\n  // `{data:null}` — is not-found, never a half-populated object handed to the renderer.\n  if (j?.data?.id) return fromApi(j.data);\n  if (j) return null;\n\n  // `j === null` means the API was never reached at all (build time, or local dev with no base URL),\n  // which is a different condition from \"the API answered and there is no such event\".\n  return MOCK_EVENTS.find((e) => e.id === id) ?? null;\n}\n```\n\nThe first guard stops the malformed request being sent. The second stops a wrong-shaped response being\n\ntrusted if one arrives anyway. The third line matters for a reason worth stating: **\"the API said no\"\nand \"I never reached the API\" have to stay distinguishable**, or a build-time render quietly turns\n\nPutting all this in `getEvent()`\n\nrather than in the page component covers three call sites at once: the\n\npage, `generateMetadata`\n\n, and the OpenGraph image route. Fixing it in the component would have left two\n\nof those still 500ing, and OG image failures are especially quiet — nobody notices a missing preview\n\ncard until someone shares the link.\n\nAll four URLs are 404s now:\n\n```\n/event/google-                404\n/event/aws-                   404\n/ja/event/groq-groq-          404\n/pricing/history/deepseek-    404\n```\n\nThe grep that would have found it for me:\n\n```\n# a template literal that interpolates straight into a path segment\ngrep -rnE '`[^`]*/\\$\\{[A-Za-z_]+\\}`' src/\n```\n\nThen, for each hit, three questions:\n\n`split()`\n\nor a `slice()`\n\ncan be. Mine came from `lastIndexOf`\n\n.`200`\n\nwith a different shape, you\nhave this bug waiting. If it `404`\n\ns or `405`\n\ns, you don't. This is worth one curl:\n`curl -i https://api.example.com/v1/things/`\n\n`.id`\n\noff an array\nreturns `undefined`\n\nrather than throwing, so the failure surfaces far away from its cause — in my\ncase several components later, on a `.charAt(0)`\n\n.If you own the API as well as the client, there is a fourth option that fixes it for every consumer at\n\nonce: make the collection path reject a trailing slash instead of serving the list. I didn't, because\n\nthe list endpoint is a real endpoint that real callers use — but if yours isn't, that's the cheaper fix.\n\nThe one-line version: **check the id before you build the URL, and check the response carries an id\nbefore you trust it.** Neither check is clever. Both were missing.\n\n*Found 2026-08-08, fixed the same day. The tracker this came out of is at\naichangewatch.com — it watches AI vendor docs for changes, which is how it\nends up with a lot of URLs that crawlers like to truncate.*", "url": "https://wpnews.pro/news/a-missing-id-doesn-t-404-it-fetches-the-whole-collection", "canonical_source": "https://dev.to/ai_changewatch/a-missing-id-doesnt-404-it-fetches-the-whole-collection-3ii2", "published_at": "2026-08-18 12:00:00+00:00", "updated_at": "2026-08-18 12:14:24.657333+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["AI Change Watch", "Cloudflare"], "alternates": {"html": "https://wpnews.pro/news/a-missing-id-doesn-t-404-it-fetches-the-whole-collection", "markdown": "https://wpnews.pro/news/a-missing-id-doesn-t-404-it-fetches-the-whole-collection.md", "text": "https://wpnews.pro/news/a-missing-id-doesn-t-404-it-fetches-the-whole-collection.txt", "jsonld": "https://wpnews.pro/news/a-missing-id-doesn-t-404-it-fetches-the-whole-collection.jsonld"}}