feat(api): give every error envelope a meta.errorCode

Roughly half of the refusals answered prose alone, so a client could branch
on the code for one 404 and had to fall back to the status for the next.
apiHandler now completes every error envelope with the status's generic
http.* code when the route named none; a route's own code still wins. The
family joins the published catalogue, and the profile form only shows a
code's sentence when the code has one.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 05:53:37 +02:00
1 parent c7f69fc212
commit fbbb535832
11 files changed
+219 -50

No files matched your search

+38 -29
View File
@@ -80,10 +80,10 @@ paths:
403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this
route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that
is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a
password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the
route refused the credential it was handed, not that the response is malformed. A session that could not be
checked because the session store was unreachable is NOT a 401: it answers 503 with
`auth.session.unavailable`, and the client keeps its credential and retries."
password confirmation — may answer 401 with the generic `http.unauthenticated`, which means the route
refused the credential it was handed. A session that could not be checked because the session store was
unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its
credential and retries."
content:
application/json:
schema:
@@ -36097,15 +36097,19 @@ components:
description: "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be
reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four
naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped
client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a
refusal you do not recognise, not as a malformed response — since the list grows with the surfaces.
Three families are outside it on purpose: `assistant.disabled.<switch>` is built from a template so the
last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`,
and `assistant.disabled.coach` is the one the native client names; the integration-probe classes
(`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the
per-provider additions) are enumerated in each `/test` operation instead, where the differences can be
stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai —
ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling,
client branches on. Every error envelope carries a code: when the route has no reason of its own to
name, the code is the status's generic one from the `http` family (`http.not_found` for a 404,
`http.internal` for a 500, `http.client_error` / `http.server_error` for a status without its own
entry), so `http.*` says which kind of refusal it was and never why. Treat an unlisted code the way you
would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response —
since the list grows with the surfaces. Three families are outside it on purpose:
`assistant.disabled.<switch>` is built from a template so the last segment is open — it is one of
`coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, and `assistant.disabled.coach` is the
one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`,
`upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each
`/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a
credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded,
ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling,
ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider
— ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut,
aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis —
@@ -36152,18 +36156,22 @@ components:
environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found;
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy,
import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded,
insights.generate.outboundScreened, insights.generate.profileScopeChanged,
insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid,
labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch,
labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed,
labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic,
labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound,
labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
health_score_config.too_narrow; http — http.bad_gateway, http.bad_request, http.client_error,
http.conflict, http.forbidden, http.gateway_timeout, http.gone, http.internal, http.invalid,
http.method_not_allowed, http.not_found, http.payload_too_large, http.rate_limited, http.server_error,
http.unauthenticated, http.unavailable, http.unsupported_media_type; illness — illness.day-log.invalid,
illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid,
illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import —
import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights —
insights.generate.budgetExceeded, insights.generate.outboundScreened,
insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate,
labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound,
labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge,
labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch,
labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
labs.update.qualitativeExpected, labs.update.referenceRangeInvalid,
labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found;
managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
@@ -36260,10 +36268,11 @@ components:
- data
- error
additionalProperties: false
description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable
machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue
validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in
one round-trip."
description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` always carries a stable
machine code — the route's own where it names one, otherwise the status's generic `http.*` code —
`meta.requestId` names the request for a support conversation, and `meta` may carry further per-refusal context.
A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong,
so a client can fix them in one round-trip."
ValidationIssue:
type: object
properties: