# http-client-react — design contract

`@archon-research/http-client-react` binds TanStack Query to an `openapi-fetch`
client. This document is the authoritative statement of what the layer
guarantees, what it deliberately refuses to do, and why.

## Premise: the generated `paths` type is the endpoint definition

There is exactly one description of the API in a consuming app: the
`openapi-typescript` output. `createQueryApi` reads everything off it — which
methods exist on which paths, what params and body each takes, what its 2xx
response is, what its error responses are. Nothing is restated in a hand-written
endpoint registry, a key factory, or a hooks file, because every restatement is
a thing that can drift from the spec while still compiling.

The consequence worth naming: **a mock layer belongs on the same premise.** The
sibling `http-client-msw` package derives msw request handlers from the same
`paths` type, so a fixture that no longer matches the API fails to typecheck
rather than silently passing a test.

## Verdict on `openapi-react-query`

We considered depending on `openapi-react-query` (the openapi-ts ecosystem's own
react-query binding) and **hand-rolled instead**, reusing its type *shape* as
prior art.

`openapi-react-query` implements exactly two things: a query key of
`[method, path, init]`, and a `queryFn` that calls the client and re-throws
`error`. Every v1 requirement here replaces one of them:

1. **The key must be canonical.** Its key holds the caller's `init` verbatim, so
   `{ limit, search }` and `{ search, limit }` are different keys for partial
   matching, and a `signal` or a `headers` object in the init lands in the key.
   We cannot fix that by overriding `queryKey`, because its `queryFn` reads the
   init back *out of the key* — a sanitized key would change the request.
2. **Failures must carry status and the parsed body.** It rethrows the bare error
   body, which loses the status code. Its `queryFn` is internal, so this is not
   overridable.
3. **Middleware over the parsed result.** It has none, and `openapi-fetch`'s own
   `use()` middleware works on `Request`/`Response`, which is the wrong altitude
   for response validation.
4. **Tags and invalidation.** It has none.

So the dependency would have bought only the generic signature types — and those
we can get from `openapi-fetch`'s own exported helpers (`MaybeOptionalInit`,
`FetchResponse`) plus `openapi-typescript-helpers`, both already in the tree.
Against that, it adds a package whose react-query and `openapi-fetch` peer
ranges have to stay compatible with our exact pins, for code we override
entirely. The hand-rolled version is ~250 lines of types and ~120 of runtime.

What we *did* borrow, deliberately, is the generic signature shape:
`<TMethod, TPath, TInit, TResponse, TOptions>` with the conditional
`...[init, options]` tuple, the self-referential `TOptions` constraint that makes
`select` infer, and `NoInfer` on the return type. That shape is well-tested
upstream and reinventing it would have been strictly worse.

One dependency was added: `openapi-typescript-helpers@0.1.0`, a types-only
package that `openapi-fetch` already depends on, re-exported from
http-client-core so both packages type against one pinned copy of the OpenAPI
helper types.

## Query key contract

A key is always exactly three elements:

```
[method, path, sanitizeQueryInit(init)]
```

- `method` is the lowercase OpenAPI method; `path` is the path *template*, braces
  intact. Together they are the operation, so `['get', '/users']` prefix-matches
  every cached variant of that endpoint.
- The third element is the **identity-bearing** part of the init only: path
  params, query params, and the body, with object keys sorted at every depth and
  `undefined`-valued properties removed. Arrays keep their order.
- `undefined`, `{}`, `{ params: {} }`, and
  `{ params: { query: { after: undefined } } }` all sanitize to `{}` and share a
  cache entry.
- Per-call transport concerns — `signal`, `fetch`, `headers`, `baseUrl`,
  `parseAs`, the serializers — are **excluded**. They are either
  non-serializable or, in the case of headers, credentials that have no business
  being visible in a cache key or in devtools.

react-query's own `hashKey` already sorts plain-object keys, so canonicalizing is
not what makes two orderings hit the same entry. What it buys is everything that
compares keys *structurally*: partial `invalidateQueries` matching
(`partialDeepEqual` is order- and `undefined`-sensitive), `exact` filters, and a
key you can read in devtools.

**Consequence for header-varying responses.** Because headers are not part of
identity, two requests that differ only by header share a cache entry. If a
response genuinely varies by header (a tenant selector, say), spread the options
and override `queryKey` yourself — the `queryFn` closes over the init rather
than reading it out of the key, so a custom key still issues the right request.

## Error contract

`openapi-fetch` resolves both outcomes into `{ data, error }`; react-query only
treats a rejection as failure. The `queryFn` converts:

- Non-2xx (or any `error`) rejects with `HttpRequestError`, carrying `status`,
  `statusText`, the parsed `body` typed from the operation's error responses, the
  `method`/`path`, and the raw `Response`. `body` is `TBody | undefined`, not
  `TBody`: a 5xx from something in front of the API, or any non-2xx with
  `Content-Length: 0`, leaves `openapi-fetch` nothing to parse. Narrowing with
  `isHttpRequestError` gets you `status` for free but still not a body.
- A 204, or a response with `Content-Length: 0`, resolves to `null` — react-query
  rejects `undefined` as query data.
- A `HEAD` resolves to `null` unconditionally. A HEAD response has no body by
  definition, so `openapi-fetch` parses none; its `Content-Length` echoes the
  size the matching `GET` would have returned, so neither check above catches it.

The declared error type is `HttpRequestError<TErrorBody> | Error`, not
`HttpRequestError` alone. That is honest rather than convenient: a transport
failure or a middleware (response validation, for instance) rejects with
something that is not an HTTP error, so `status` is reachable only after
`isHttpRequestError(error)`. The guard matches on `name` rather than `instanceof`
so it survives a consumer ending up with two copies of the package.

`createZodResponseMiddleware`'s rejection is the one of those with a guard of its
own, `isZodResponseValidationError`, because the retry policy has to tell it from
a transport failure — it is the only statusless rejection this package raises
after the request completed.

The guard takes **no body type parameter**. A check on `name` cannot say
anything about the body's shape, so a parameter would only have let a call site
name a type and get it back unchecked. It derives the body type from what the
argument already declares instead: narrowing a `QueryApiError<TErrorBody>` keeps
that operation's error body, and narrowing a `catch` binding keeps `unknown`.

## Middleware contract

`(ctx, next) => Promise<unknown>`, onion order: the instance chain outermost in
declared order, a per-call chain appended innermost. `ctx` carries `method`,
`path`, `operationType` (`'query' | 'mutation'`), and `init` — the init as it
will be handed to `openapi-fetch`, including react-query's abort signal.

Middleware sees the **parsed result**, not `Request`/`Response`. That is the
altitude response validation, timing, and result shimming want. Anything that
needs the raw HTTP objects belongs in `openapi-fetch`'s own `client.use()`.

`next()` with no argument passes the context through; `next(ctx)` rewrites it for
everything downstream. Not calling `next` at all short-circuits the chain with a
substitute result. Calling it twice rejects rather than issuing the request
twice — which also means **a middleware cannot retry**: it has no way to re-issue
the request it is wrapping. Whole-operation retry is react-query's `retry`/
`retryDelay`, which re-enter the chain from the top; transport-level retry
belongs in the `fetch` handed to `createApiClient`, which owns the HTTP call.

The one shipped middleware, `createZodResponseMiddleware`, validates bodies
against the OpenAPI document through http-client-core's
`getComponentSchemaFromOpenApi`, so the runtime schema and the static types come
from the same spec. It **passes the body through unmodified** — it never
substitutes zod's parse output, so the runtime value always matches the
statically inferred one and no coercion happens behind the caller's back.
Compiled schemas are memoized per middleware instance, because
`z.fromJSONSchema` is far too expensive per request.

## Tag contract

Tags are declared in two places and nowhere else:

- the vocabulary, on `createQueryApi(client, { tags })` — which both infers
  `TTag` (so `tags` and `invalidates` are checked against a closed set) and makes
  an unknown tag throw at runtime, catching typos from untyped call sites;
- membership, on the `queryOptions` call that belongs to the tag.

Membership is recorded where the query is described, so the query stays the
single place its cache behaviour lives. On mutation success, tags resolved from
`invalidates` are invalidated through the `QueryClient` react-query passes to the
mutation callback — no `useQueryClient` at the call site, no `QueryClient`
threaded into the api instance.

**An unknown tag fails where it is written, not where it is used.** A literal in
`tags` or `invalidates` is checked when the options are built — before anything
has run — so it throws, which is what catches a typo from an untyped call site.
A tag an `invalidates` callback derives from the mutation's result can only be
checked after the mutation has already succeeded, and throwing there would report
the succeeded mutation as failed and skip the caller's own `onSuccess`. So an
unknown derived tag is dropped, with one `console.warn` per distinct tag per api
instance in a non-production build. The mutation's outcome never depends on the
tag vocabulary.

**Granularity is per `${method} ${path}`, not per key.** Invalidating a tag
invalidates every cached variant of the endpoints under it, whatever their
params. That is what "refetch the user list" almost always means, and it keeps
the registry bounded by endpoint count rather than by cache size. The corollary:
two queries on one endpoint with different tags cannot be invalidated
independently — use the key directly for that.

### The registry's boundary

A tag matches only the endpoints some `queryOptions` call has registered on this
api instance, in this session. Registration happens as a side effect of building
the options, so the registry is empty until the first call that declares a tag —
and it is genuinely possible to have a cache entry that no `queryOptions` call
ever described:

- `queryClient.setQueryData(api.queryKey('get', '/positions'), …)` — a key
  without options, written directly;
- `prefetchQuery`/`fetchQuery` against `api.queryKey(…)` rather than against
  `api.queryOptions(…)` — which drops more than the tag, and is written up in
  full in [the README](./README.md#prefetching-pass-queryoptions-never-querykey);
- SSR or persisted-cache **hydration**, which restores entries before any render
  has run.

Those entries are cached under a `[method, path, …]` key like any other, but the
tag does not know the endpoint, so `invalidateTags` and `tagFilter` skip them
silently. This is a real hole, not a theoretical one: the invalidation looks
correct and simply does nothing.

Two ways out, both explicit:

1. **Register the endpoint once, at module scope.** Building the options is what
   registers, so the result can be discarded — the call is the registration.

   ```ts
   // Registers `get /positions` under `positions` at import time, so a later
   // hydrated or hand-written entry for it is in scope for the tag.
   void api.queryOptions('get', '/positions', undefined, { tags: ['positions'] });
   ```

2. **Invalidate by key instead of by tag.** The first two elements of a key are
   the operation and react-query prefix-matches, so
   `invalidateQueries({ queryKey: ['get', '/positions'] })` reaches every cached
   variant whether or not the registry has heard of it.

The registry is deliberately not fixed by scanning the cache for `[method, path]`
pairs: that would make a tag's membership depend on what happens to be cached at
the moment of invalidation, which is a worse contract than one that is empty
until declared.

## QueryClient defaults

`createQueryClient` exists because react-query's defaults are tuned for a
document-shaped app and this package's consumers are not building one. Two are
changed; the deliberate part is how few.

**`refetchOnWindowFocus: false`.** The default is `true`, which means returning
to a tab reissues every active query. On a dashboard that is a visible reload of
panels the user was reading, triggered by an action that expressed no intent to
reload. Freshness belongs to `staleTime` and to explicit invalidation, both of
which the app already controls.

The consequence is that **the app owns error recovery**: a mounted query that has
exhausted its retries makes no further attempt on its own while the tab stays
open and the connection stays up, and react-query keeps serving the last
successful `data` beside `status: 'error'` — so a session that expired under an
open dashboard reads as current numbers rather than as an empty panel.
`refetchOnReconnect` and `refetchOnMount` are left at react-query's defaults, so
a dropped connection returning or a remount still refetches; in between, showing
the error and offering a refetch is the app's job, not this package's.

**A status-aware `retry`.** The default retries every rejection three times. The
policy here splits on what the status *says*: a 5xx is a well-formed request
meeting an unwell server, so ask again; 408, 425, and 429 name transient
conditions — a server-side request timeout, a TLS early-data refusal, rate
limiting — so ask again; every other 4xx says the request itself is wrong, and
repeating it unchanged buys nothing but latency. Below 400 is not retried
either: a 304 on the error path is a caching bug.

A rejection carrying no status is retried. That covers a dropped connection, a
CORS refusal, an abort, and a middleware that threw, and it is the deliberately
optimistic branch — the alternative, treating an unrecognised rejection as fatal,
turns one dropped socket into an error state. The cost of being wrong is two
extra requests; the cost of the other default is a screen that fails on a blip.

One statusless rejection is exempt, and it is this package's own.
`ZodResponseValidationError` has no status because `createZodResponseMiddleware`
rejects *after* a 2xx arrived and parsed: the request completed, and the body the
server sent will not have changed by the next attempt. Retrying it costs two more
round trips to reach the same mismatch three seconds later — precisely the cost
the status policy above exists to avoid on a 422. `isZodResponseValidationError`
is what the predicate narrows with, on `name` rather than `instanceof`, for the
same duplicate-copy reason as `isHttpRequestError`.

Two retries rather than react-query's three, so that with the default
exponential `retryDelay` an error surfaces in about three seconds instead of
seven. A panel sitting pending over a fault that will not clear is worse than an
error state that arrives promptly.

**Mutations get nothing.** react-query already does not retry them, and it is
right: a `POST` that failed after reaching the server may have applied, and
nothing at this layer can distinguish that from one that did not.

### What is not in the defaults

**`staleTime`.** How long a screen may show a stale number is a product
decision. A package-level value would be wrong silently, in whichever direction
it was wrong.

**A `QueryCache.onError` driven by per-query `meta`.** Every consumer wants one,
which is an argument for shipping it, and the two reasons not to are both
concrete. The half with the value in it is the sink — the app's toast or logger
— which this package has no business owning. And typing the `meta` it reads
requires augmenting react-query's `Register` interface, a global declaration
that can be made exactly once in a module graph; making it here would collide
with the app's own augmentation and leave the consumer worse off than if the
package had stayed out of it. The README carries it as a recipe instead.

### Overriding

The retry policy has to be replaceable without giving up the rest, so
`createQueryClient` takes react-query's own `QueryClientConfig` and merges it one
option deep: a caller's `defaultOptions.queries.retry` replaces the default of
that name and leaves `refetchOnWindowFocus` standing. Anything outside
`defaultOptions` — `queryCache`, `mutationCache` — passes straight through.

The `queries` merge drops the caller's explicitly-`undefined` keys first, and
that step is the difference between a sound override and a silent one. A spread
cannot tell an absent key from one present with the value `undefined`, and the
second shape is how conditional config is ordinarily written:
`{ retry: cond ? false : undefined }`. Spread raw, the else-branch copies
`retry: undefined` over the predicate, react-query resolves it as `retry ?? 3`,
and a mounted query asks four times where it should ask once — reinstating the
retry-everything default this whole section exists to replace. Nothing looks
wrong from outside, either: `refetchOnWindowFocus: false` survives the same
spread, so the client still reads as configured. Dropping the key instead is
lossless, because react-query resolves each of these options with `?? <default>`
or an `=== undefined` check, so absent and `undefined` already mean the same
thing to it — there is no option where "present but undefined" says something an
explicit value could not say more plainly.

For the middle case, where the status policy is right but the shape around it is
not, the policy is exported in three pieces: `shouldRetryRequest` (the
react-query-shaped predicate, for wrapping), `isRetryableError` (the error
policy, for changing how many times a request is repeated), and
`isRetryableHttpStatus` (the status policy alone). A consumer composes rather
than restates, so a change to the status table reaches them.

## Type-level guarantees

`createQueryApi`'s implementation is written against loose types and cast once,
at the return. `src/query-api.types.test.ts` is what holds the cast and the
declared surface in agreement — it asserts data/error/param/variable inference
and `@ts-expect-error`s the calls that must not compile. It is checked by
`npm run type:check`, not by the vitest run.

`TPaths` is constrained by `QueryApiPaths` on `createQueryApi` itself, not only
inside the option types — the constraint is the same one either side, so there is
one statement of what a paths type is. Its shape is dictated by the generated
output: every method **optional**, because `openapi-typescript` emits absent
operations as `put?: never`, and every operation payload `any`, because
narrowing it makes `TPaths[TPath][TMethod]` resolve to `<payload> | undefined`,
which fails `FetchResponse`'s own `Record<string | number, any>` constraint.
Nothing is lost by the `any`: the types the api reports are read off the
caller's `TPaths`, never off the constraint.

The limit worth knowing: this rejects a bad *explicit* type argument, not a bad
*client*. Given a client whose paths type is not a paths map, inference finds no
candidate for `TPaths` and falls back to the constraint, so `createQueryApi`
returns `QueryApi<QueryApiPaths>` — an api on which no `queryOptions` call
typechecks. The error surfaces at the calls rather than at the construction.

Both `queryKey` and `queryOptions` wrap their return in `NoInfer`. Without it, a
contextual type on the result — the `queryKey` property of an
`invalidateQueries` filter, say — becomes an inference site that leaves `TInit`
unresolved and makes TypeScript demand the optional `init` argument.

## Deliberately out of v1

- **Optimistic updates and cache updaters.** No `onMutate` rollback helpers, no
  string-DSL updaters. They need per-endpoint knowledge of how a mutation's
  result maps into a query's shape, which is the one thing the OpenAPI document
  does not describe. Invalidation is correct without that knowledge.
- **Cross-tab sync.** No `BroadcastChannel`; use react-query's own persistence
  and broadcast plugins if a consumer needs it.
- **`POST`-backed reads.** `queryOptions` accepts `get` and `head` only. A
  `POST /search` used as a read has to go through `mutationOptions` for now.
- **Infinite queries and suspense wrappers.** Neither has a consumer yet; both
  are additive.
- **Header-varying cache identity.** See the query key contract above.

## Deprecated

`createQueryOptions(queryKey, queryFn)` — the pre-`createQueryApi` shim that
takes a hand-written key and function. Kept working for existing call sites,
marked `@deprecated`, and not to be used in new code.
