A database outage should not be reported as 401 Unauthorized - #59
Conversation
Authentication reads the database, so anything that can go wrong with the
database surfaces as an exception in the auth middleware - and the catch turned
every one of them into 401 with the driver's message as the body:
401 {"error":"connect ECONNREFUSED 127.0.0.1:27017"}
401 {"error":"Operation `users.findOne()` buffering timed out after 10000ms"}
The status says the caller sent a bad key. It sends whoever is debugging to
look at credentials that were never judged, because the request never got far
enough to judge them.
The healthcheck agreed: it returned 200 unconditionally, proving only that the
HTTP listener was up. A SPACE whose MongoDB container had stopped reported
healthy for fifteen hours while refusing every authenticated request as
unauthorised. Two signals agreeing on the wrong answer is what made this
expensive.
authenticateUserApiKey and authenticateOrgApiKey now throw a typed
InvalidApiKeyError for the two cases that really are bad credentials. The catch
answers 401 only for those; anything else is 503 with Retry-After, since the
credential was never judged and the caller should try again.
The healthcheck reports 503 when Mongoose is not connected, and pings the
database when it is - readyState is what the driver believes, a ping is what
the database says, and they disagree when a connection has gone stale.
Verified by stopping the database under a running server:
main: healthcheck 200, authed call 401 {"error":"connect ECONNREFUSED…"}
this PR: healthcheck 503 {"database":"disconnected"}
authed call 503 {"error":"Space cannot verify credentials right now."}
|
The red Integration Tests Run check on this PR is not caused by the change. The job reads its Mongo port and database name from the The workflow has never passed on a fork PR — every green run in its history came from a branch inside the repository. #62 fixes it, and its own check is green, which is the fix running on a fork PR. Once that lands this check should go green here too. |
Narrowing the catch to a dedicated error type was not enough on its own: UserService.findByApiKey reports an unknown key by throwing rather than by returning nothing, so the commonest 401 in the suite was being answered 503. The lookup is now wrapped in one place that tells the codebase's own INVALID DATA: signal - a caller's mistake - apart from anything else, which is the database being unable to answer. Both directions are pinned by tests.
|
Full suite, verified locally. Since the workflow cannot run on a fork PR until #62 lands, I reproduced the CI environment (Mongo 7.0.16 on 27017, Redis 7 on 6379, an That is the repository's 12 files plus the one this PR adds. The same run on One note on method, since it changed a conclusion: an earlier run of mine reported failures in |
The problem
Authentication reads the database. So does every route behind it. When the
database is unreachable,
authenticateApiKeyMiddlewarecatches the resultingexception and answers 401:
which produces things like:
The status says the caller sent a bad key. The body says Mongoose could not
reach the database. Only the body is true, and most clients only look at the
status.
Meanwhile
/api/v1/healthcheckreturns 200 unconditionally — it proves theHTTP listener is up and nothing else.
Why it matters
I lost most of a day to this. A SPACE instance had been up for fifteen hours,
its healthcheck passing the whole time, with its
mongodbcontainer stopped.Every authenticated request came back 401, so the first assumption was a wrong
API key — then a wrong admin password, then a mis-copied organization key. The
healthcheck confirmed, repeatedly, that the service was fine.
Two signals agreeing on the wrong answer is what made it expensive. Either one
alone would have been survivable.
It also defeats an orchestrator: a container that reports healthy while unable
to serve a single request is one Kubernetes or Compose will keep sending
traffic to, and will never restart.
The change
Authentication.
authenticateUserApiKeyandauthenticateOrgApiKeythrow atyped
InvalidApiKeyErrorfor the two cases that really are bad credentials(no such user, no such organization). The catch answers 401 only for those.
Anything else is a
503withRetry-After, because the credential was neverjudged — the request did not get that far — and the caller should try again
rather than go looking at their key. The underlying error is logged and
included as
details.Healthcheck. 503 when Mongoose is not connected, and when it is, a
pingbefore answering 200:
readyStateis what the driver believes, a ping is whatthe database says, and the two disagree when a connection has gone stale. The
response now carries
database: "connected" | "disconnected" | "unreachable".Verification
Stopping the database under a running server, same request both ways:
With the database up, both branches behave identically: healthcheck 200, and an
unknown key still 401 — see the correction below, which is where that claim
first went wrong.
pnpm run build(tsc) passes.A note on scope
I have deliberately not touched the other
catchblocks that map errors tostatuses, nor added a readiness probe distinct from liveness — either could be
worth doing, but this PR is about the one pair of signals that actively
misleads. Happy to extend it if you would like the wider change.
Correction and follow-up commit
The first version of this PR broke a 401. I ran the repository's full suite
per file afterwards and
authMiddleware.test.tsfailed:My original claim above that "an unknown key still 401" was checked by hand
against an organization key and did not hold for the user path. The reason:
The service never returns nothing for an unknown key — it throws a plain
Error. So theif (!user) throw new InvalidApiKeyError(...)guard I added wasunreachable, and the real rejection fell through to the new
503branch.Narrowing the catch to a dedicated error type is not by itself sufficient when
the failure being narrowed away is signalled the same way.
The follow-up commit wraps both credential lookups in one place that tells the
codebase's own convention apart from everything else —
INVALID DATA:is theprefix this repository already uses for a caller's mistake, so it stays a 401,
and anything else is the database being unable to answer:
Verification of the follow-up
api/src/test/middlewares/authOutage.test.tsis new and pins bothdirections, since the whole change is about the boundary between them:
Each direction was confirmed load-bearing by reverting the source:
401this PR replacesINVALID DATA:classificationThe suite this PR broke is green again:
authMiddleware.test.ts, 68 passed.npx tsc --noEmitis clean.