An id that names nothing is answered rather than refused #48

Closed
opened 2026-08-03 22:21:49 +02:00 by Claude · 3 comments
Collaborator

An id that names nothing is answered rather than refused.

Every one of these was measured against a production database, where the id is
one no row carries:

request upstream ours
/Items?ParentId=<unknown> 400 200
/Genres?ParentId=<unknown> 400 200
/Studios, /Artists, /Artists/AlbumArtists, /MusicGenres — same 400 200
/Items/Filters?ParentId=<unknown> 400 200
/Items/<unknown>/InstantMix 404 200
/Items/<unknown>/RemoteImages 404 200
/Users/<unknown>/Items/<id>/Intros 404 200
/Persons/<unknown name> 404 200
/Items/<unknown>/Collections 401 200

A client asking about something that is gone is told it is fine and handed an
empty answer, where upstream tells it the id is bad. The last one is the odd
member of the set — upstream refuses it as unauthorized rather than missing.

Found by replaying scenarios written for the reference library against a
different one, so every id in them named nothing. Against the fixture they all
name something, which is why this has never shown up.

An id that names nothing is answered rather than refused. Every one of these was measured against a production database, where the id is one no row carries: | request | upstream | ours | |---|---|---| | `/Items?ParentId=<unknown>` | 400 | **200** | | `/Genres?ParentId=<unknown>` | 400 | **200** | | `/Studios`, `/Artists`, `/Artists/AlbumArtists`, `/MusicGenres` — same | 400 | **200** | | `/Items/Filters?ParentId=<unknown>` | 400 | **200** | | `/Items/<unknown>/InstantMix` | 404 | **200** | | `/Items/<unknown>/RemoteImages` | 404 | **200** | | `/Users/<unknown>/Items/<id>/Intros` | 404 | **200** | | `/Persons/<unknown name>` | 404 | **200** | | `/Items/<unknown>/Collections` | 401 | **200** | A client asking about something that is gone is told it is fine and handed an empty answer, where upstream tells it the id is bad. The last one is the odd member of the set — upstream refuses it as unauthorized rather than missing. Found by replaying scenarios written for the reference library against a different one, so every id in them named nothing. Against the fixture they all name something, which is why this has never shown up.
Author
Collaborator

The rule, read from upstream.

LibraryManager.GetParentItem (LibraryManager.cs:3109) is what every route
taking a parentId resolves through:

return GetItemById(parentId.Value)
    ?? throw new ArgumentException($"Invalid parent id: {parentId.Value}");

and ExceptionMiddleware.GetStatusCode (ExceptionMiddleware.cs:127) maps the
exception types onto the statuses:

thrown answered
ArgumentException 400
AuthenticationException 401
ResourceNotFoundException, FileNotFoundException, DirectoryNotFoundException 404
anything else 500

So the 400s in this issue are one cause with seven call sites — the controllers
that call GetParentItem are Items, Filter, Artists, Genres,
MusicGenres, Studios and Years. A parent id naming nothing is not a filter
that matches nothing; it is a bad argument.

The 404s are a separate group: a route that looks an item up and answers
NotFound when it is not there. The one 401 (/Items/{id}/Collections) is a
third, and reads as an authentication failure raised before the lookup.

Holding the change until #47, #49, #52 and #53 land — they are being fixed in
the same route files and would conflict.

The rule, read from upstream. `LibraryManager.GetParentItem` (`LibraryManager.cs:3109`) is what every route taking a `parentId` resolves through: return GetItemById(parentId.Value) ?? throw new ArgumentException($"Invalid parent id: {parentId.Value}"); and `ExceptionMiddleware.GetStatusCode` (`ExceptionMiddleware.cs:127`) maps the exception types onto the statuses: | thrown | answered | |---|---| | `ArgumentException` | 400 | | `AuthenticationException` | 401 | | `ResourceNotFoundException`, `FileNotFoundException`, `DirectoryNotFoundException` | 404 | | anything else | 500 | So the 400s in this issue are one cause with seven call sites — the controllers that call `GetParentItem` are `Items`, `Filter`, `Artists`, `Genres`, `MusicGenres`, `Studios` and `Years`. A parent id naming nothing is not a filter that matches nothing; it is a bad argument. The 404s are a separate group: a route that looks an item up and answers `NotFound` when it is not there. The one 401 (`/Items/{id}/Collections`) is a third, and reads as an authentication failure raised before the lookup. Holding the change until #47, #49, #52 and #53 land — they are being fixed in the same route files and would conflict.
Author
Collaborator

A parent naming nothing is now a bad argument rather than a filter that matches
nothing.

Upstream resolves one through LibraryManager.GetParentItem
(LibraryManager.cs:3109):

return GetItemById(parentId.Value)
    ?? throw new ArgumentException($"Invalid parent id: {parentId.Value}");

and ExceptionMiddleware.GetStatusCode (ExceptionMiddleware.cs:127) turns
ArgumentException into 400, AuthenticationException into 401 and the
not-found family into 404.

So it is one cause with a fixed set of call sites, and the important half of the
work was finding which routes are not among them. Verified live against a
production instance, an id no row carries:

route upstream before now
/Items 400 200 400
/Genres, /MusicGenres, /Artists, /Artists/AlbumArtists, /Studios 400 200 400
/Items/Filters 400 200 400
/Items/Filters2 200 200 200
/UserItems/Resume 200 200 200
/Items/Suggestions 200 200 200
/Search/Hints 200 200 200

11 of 11 now agree.

/Items/Filters2 is the one I got wrong first. Both filter listings share one
narrowing function here, so refusing inside it made Filters2 refuse too — and
upstream only calls GetParentItem from GetQueryFiltersLegacy
(FilterController.cs:71), not from GetQueryFilters. Caught by checking every
route against the running upstream rather than only the ones the issue named.
GetResumeItems is the same shape: it reads parentId ?? Guid.Empty straight
into the query and never resolves it.

There are tests both ways — the routes that refuse, and the ones that must keep
answering.

The 404 and 401 cases in this issue are separate causes: a route that looks an
item up and answers NotFound, and one authentication failure raised before the
lookup. Neither is the parent-resolution rule, and neither is fixed here.

A parent naming nothing is now a bad argument rather than a filter that matches nothing. Upstream resolves one through `LibraryManager.GetParentItem` (`LibraryManager.cs:3109`): return GetItemById(parentId.Value) ?? throw new ArgumentException($"Invalid parent id: {parentId.Value}"); and `ExceptionMiddleware.GetStatusCode` (`ExceptionMiddleware.cs:127`) turns `ArgumentException` into 400, `AuthenticationException` into 401 and the not-found family into 404. So it is one cause with a fixed set of call sites, and the important half of the work was finding which routes are *not* among them. Verified live against a production instance, an id no row carries: | route | upstream | before | now | |---|---|---|---| | `/Items` | 400 | 200 | 400 | | `/Genres`, `/MusicGenres`, `/Artists`, `/Artists/AlbumArtists`, `/Studios` | 400 | 200 | 400 | | `/Items/Filters` | 400 | 200 | 400 | | `/Items/Filters2` | **200** | 200 | 200 | | `/UserItems/Resume` | 200 | 200 | 200 | | `/Items/Suggestions` | 200 | 200 | 200 | | `/Search/Hints` | 200 | 200 | 200 | 11 of 11 now agree. `/Items/Filters2` is the one I got wrong first. Both filter listings share one narrowing function here, so refusing inside it made Filters2 refuse too — and upstream only calls `GetParentItem` from `GetQueryFiltersLegacy` (`FilterController.cs:71`), not from `GetQueryFilters`. Caught by checking every route against the running upstream rather than only the ones the issue named. `GetResumeItems` is the same shape: it reads `parentId ?? Guid.Empty` straight into the query and never resolves it. There are tests both ways — the routes that refuse, and the ones that must keep answering. The 404 and 401 cases in this issue are separate causes: a route that looks an item up and answers `NotFound`, and one authentication failure raised before the lookup. Neither is the parent-resolution rule, and neither is fixed here.
Author
Collaborator

The 404 half is done too, and 22 of 22 probes now agree with a running
upstream.

request upstream before now
/Items/<unknown>/InstantMix, /Albums/…, /Songs/… 404 200 404
/Items/<unknown>/RemoteImages (+ /Providers) 404 200 404
/Items/<unknown>/Collections 404 200 404
/Users/<unknown>/Items/<unknown>/Intros 404 200 404
/Persons/<unknown> 404 200 404
/MusicGenres/<unknown>/InstantMix 200 200 200
/Genres, /Studios, /Artists by unknown name 200 200 200

The rule is that a route whose feature this project excludes still resolves the
item first — upstream answers the empty listing only once it has one. A route
taking a name resolves nothing and answers whatever the name is, which is why
/MusicGenres/<unknown>/InstantMix stays 200 while the id-taking siblings do
not.

A person is the single by-name route that refuses. Every other name has a row
stood up for it, because upstream's GetItemByName writes one; GetPerson
(PersonsController.cs:143) answers with the person it has or NotFound.

Three existing tests asserted the old answer — that an excluded feature answers
an empty listing for any id at all. They were asserting the absence of a lookup
rather than upstream's behaviour, and now use an item that exists, beside a new
test for the id that does not.

The one 401 this issue listed (/Items/{id}/Collections) does not reproduce:
upstream answers 404 there, and so do we. It was an artifact of the token the
original replay ran under.

The 404 half is done too, and **22 of 22** probes now agree with a running upstream. | request | upstream | before | now | |---|---|---|---| | `/Items/<unknown>/InstantMix`, `/Albums/…`, `/Songs/…` | 404 | 200 | 404 | | `/Items/<unknown>/RemoteImages` (+ `/Providers`) | 404 | 200 | 404 | | `/Items/<unknown>/Collections` | 404 | 200 | 404 | | `/Users/<unknown>/Items/<unknown>/Intros` | 404 | 200 | 404 | | `/Persons/<unknown>` | 404 | 200 | 404 | | `/MusicGenres/<unknown>/InstantMix` | 200 | 200 | 200 | | `/Genres`, `/Studios`, `/Artists` by unknown name | 200 | 200 | 200 | The rule is that a route whose feature this project excludes still resolves the item first — upstream answers the empty listing only once it has one. A route taking a *name* resolves nothing and answers whatever the name is, which is why `/MusicGenres/<unknown>/InstantMix` stays 200 while the id-taking siblings do not. A person is the single by-name route that refuses. Every other name has a row stood up for it, because upstream's `GetItemByName` writes one; `GetPerson` (`PersonsController.cs:143`) answers with the person it has or `NotFound`. Three existing tests asserted the old answer — that an excluded feature answers an empty listing for any id at all. They were asserting the absence of a lookup rather than upstream's behaviour, and now use an item that exists, beside a new test for the id that does not. The one 401 this issue listed (`/Items/{id}/Collections`) does not reproduce: upstream answers 404 there, and so do we. It was an artifact of the token the original replay ran under.
Sign in to join this conversation.
No description provided.