Skip to content

Build1 publisher3 min readPublished

An empty path parameter isn't a 404. It's a request for the entire collection.

A trailing hyphen in a crawled URL turned single-item lookups into full-dataset reads, and the edge logs filed every one of them as outcome: ok.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

Illustration accompanying An empty path parameter isn't a 404. It's a request for the entire collection.
Generated illustration

What happened

  • AI Change Watch is 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 change.
  • GET /v1/events/{id} with an empty id becomes GET /v1/events/, which is not a 404 but the list endpoint, and it returns 200 OK.
  • Every change on the 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 eventIdFromParam takes the index of the last hyphen and returns param.slice(i + 1), or the whole param if there is no hyphen.
  • For /event/google-, lastIndexOf('-') finds the final character, slice(i + 1) returns the empty string, and the fetch goes out as /v1/events/.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

The operator of AI Change Watch, an independent crawler that records what 15 AI vendors publish about their own models, found that `GET /v1/events/{id}` with an empty id does not return 404 [1][2]. It resolves to `GET /v1/events/`, which is the list endpoint, and the API answers 200 with `{ data: [ ...every recent event... ] }` [2][7]. That matters because the id in that path is a string assembled by hand from a URL, and truncated URLs arrive constantly: crawlers generate them, and so do chat clients and mail readers that break a long link across lines and leave the tail behind [6].

The mechanics are dull, which is the point. Every change lives at a URL like `/event/openai-gpt-4-deprecated-bf_20260723143458_0003`, slug for humans, id after the last hyphen [3]. The parser does what you would write: `lastIndexOf('-')`, then `slice(i + 1)`, falling back to the whole param if there is no hyphen [4]. Given `/event/google-`, `lastIndexOf` finds the final character, the slice returns the empty string, and the fetch goes out as `/v1/events/` [5].

On this site the collection payload was then handed to a mapper that read `.title`, `.providerName` and `.severity` off an array [8]. All undefined, no error, because reading a missing property off an array is legal [8]. The page rendered an event-shaped object with every field empty until it reached `providerName.charAt(0)` and threw [9]. So `/event/google-`, `/event/aws-` and `/ja/event/groq-groq-` returned 500 where a 404 was owed, live, for as long as those URLs had existed [10]. Note the sequencing: the array had to arrive and be mapped before the crash, so the bulk response crossed the wire on every one of those requests [1]. The visible symptom was a broken page; the invisible one was that each truncated URL bought a full recent-events read instead of a single record, and anything enumerating slug variants multiplies that one-for-one [2].

The logging made it worse. The bug was found by querying Cloudflare's observability API for 5xx responses, and the obvious filter field was useless: `$workers.outcome = "ok"` for every single one [11]. A rendered 500 is a successful worker invocation, so the signal lives in `$metadata.error`, and edge logs filtered by outcome simply do not contain application-level 500s [12]. A sibling route then hid the pattern by accident. `/pricing/history/<slug>-<id>` never 500'd, not because it was more careful, but because it filters on `type === 'pricing_changed'`; the junk object had `type: undefined`, the filter rejected it, and the page 404'd correctly [13]. That made the defect look route-specific, and time went into reading the event page, the one place it was not [14].

The general shape: a REST detail path with a missing key silently degrades into the collection path, and `/things/{id}` and `/things/` are different endpoints with different response shapes separated only by a string you built [15]. No status code marks the difference. Both are 200, both return `{ data: ... }`, and the only distinction is object versus array, which JavaScript will let you read properties off either way [16].

The fix went into the data layer, not the page: reject an empty id before the request is sent, and only treat a DTO that actually carries an `id` as an event, so a list payload or `{data:null}` becomes not-found rather than a half-populated object [17]. Worth watching in your own stack is the third condition in that helper, where a null response means the API was never reached at all, at build time or in local dev with no base URL, as distinct from the API answering that nothing exists [18]. Collapse those two and a build-time render turns every page into a 404 [18]. And check which field your edge log filter actually uses, because outcome will tell you everything is fine [12].

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories