DEVX-70: preserve GraphQL error classification in CLI envelope#27
Conversation
df081b1 to
e54f586
Compare
Route server-side GraphQL errors through a shared mapper so app lifecycle failures (create, update, archive, deploy upload) surface with accurate envelope codes and actionable fix hints instead of collapsing into a generic NETWORK_ERROR. - Add ForbiddenError, NotFoundError, ConflictError, RateLimitError tagged-error classes with matching fromTaggedError envelope cases. - Extend ValidationError with a `fields` array so per-field server validation problems reach the agent via error.details.fields. - Extract mapGraphQLError + helpers into src/services/graphql-error.ts and apply it to both applications.ts and extension/presigned-url.ts (the latter previously wrapped every failure as NetworkError, so deploy-upload failures now classify correctly too). - Preserve ApiErrorContext (status + full GraphQL response body) on the NetworkError fallback so unknown/future server codes remain inspectable via error.details.response.errors[].extensions.code. - Reclassify two client-side arktype input-validation failures in createApplication / updateApplication from NetworkError to ValidationError. - Add unit coverage for every DEVX-70 code (including *_NOT_FOUND and *_CONFLICT suffix matching) across applications.ts and extension/presigned-url.ts. Made-with: Cursor
e54f586 to
8ef1956
Compare
…-shape guards - Share a single `pickPrimaryError` selector between `extractGraphQLError` (message) and `extractExtensions` (code/fields) so both helpers always refer to the same error entry. The previous implementation read `errors[0].message` unconditionally while classification searched for the first error with a code, producing correctly-tagged CLI errors with unrelated messages when the classified entry was not at index 0. - Add `toErrorArray` helper and use it everywhere `err.response.errors` is consumed. Normalizes `null`, `undefined`, or any non-array value (shape drift from the server) to `[]` so downstream `.find`/`.map`/ index access cannot throw. - `Array.isArray` runtime guard on `ext.fields` before iteration in `mapGraphQLError`; a malformed `fields` value now degrades gracefully to `NetworkError` instead of crashing inside `.map`. - Gate the developer-facing `(code)` suffix on `typeof === "string"` so non-string codes are treated consistently in both helpers. - Drop unused `status?: number` field from `ServerExtensions`. Tests: - Strengthen the multi-error regression test to assert `message` content, not just `_tag`. - Add a sub-case where no error carries a code (consistent fallback to `NetworkError` with `errors[0].message`). - Add a defensive test proving non-array `fields` does not crash the mapper. - Parameterized test covering the five field-level classification codes (INVALID_FIELD, INSUFFICIENT_PERMISSIONS, *_NOT_FOUND, *_CONFLICT, RATE_LIMIT) introduced on top of 8ef1956 — previously untested. Made-with: Cursor
|
Nice refactor — the defensive wire parsing and test coverage are genuinely good. A few things I'd push on before merge, mostly about simplification. Main feedback: the taxonomy is over-classed
Collapsing them into one export type ServerErrorKind =
| "VALIDATION"
| "FORBIDDEN"
| "NOT_FOUND"
| "CONFLICT"
| "RATE_LIMITED";
export class ServerError extends Data.TaggedError("ServerError")<
{
readonly kind: ServerErrorKind;
readonly message: string;
readonly userMessage: string;
readonly fields?: ReadonlyArray<ErrorField>;
} & ApiErrorContext
> {}This also resolves the asymmetry where
Other issues
Envelope code divergences.
Import style inconsistency. Test clarity. VerdictShape of the refactor is right — shared mapper, HTTP context preserved on fallback, field-level classification, the |
wcole1-godaddy
left a comment
There was a problem hiding this comment.
already commented
cblecken-godaddy
left a comment
There was a problem hiding this comment.
Review: DEVX-70 — Preserve GraphQL error classification in CLI envelope
Strengths
- Good extraction pattern — 9 identical catch blocks in
applications.tscollapsed tocatch: mapGraphQLError, andpresigned-url.tsgets the same treatment. Net -76 lines from services. - Defensive wire-shape handling —
toErrorArray,Array.isArray(fields),typeof code === "string"guards throughout. ThepickPrimaryErrorapproach correctly handles multi-error responses where the classified entry isn't at index 0. - Strong test coverage — 20+ test cases including parameterized tests for field-level codes, multi-error regression, malformed
fields, transport failures, and end-to-end envelope verification. Thepresigned-url.test.tsadditions are a nice regression guard. - Correct reclassification — arktype parse failures in
createApplicationEffect/createReleaseEffectchanged fromNetworkErrortoValidationError, which is the right semantic.
Minor suggestion
safeGraphQLUserMessage still uses HTTP status codes — The function checks err.response.status for 401/403/404/5xx, but mapGraphQLError classifies based on extensions.code. This means the userMessage can contradict the error class — e.g., a 200-status response with extensions.code: "FORBIDDEN" would produce a ForbiddenError with userMessage: "An unexpected error occurred" (falls through all status checks). Not a bug per se, but the user message could be more helpful by aligning with the classification rather than HTTP status.
Verdict
The core logic is correct, well-tested, and meaningfully improves the CLI's error reporting. LGTM.
Address service-owner review on DEVX-70:
- Collapse `ForbiddenError` / `NotFoundError` / `ConflictError` /
`RateLimitError` (plus the server-side variant of `ValidationError`)
into a single `ServerError` with a `kind` discriminant. Nothing
pattern-matched on the per-kind `_tag`s in a type-driven way; the
tags were carrying data, not behavior. Adding a new server
classification is now a two-line change instead of a five-file change.
- Reserve `ValidationError` for client-side (arktype) input failures.
"We rejected your input before sending" is a different semantic than
"the server rejected your request" — collapsing the two caused the
asymmetry where `ValidationError` alone lacked `ApiErrorContext`.
- Make `pickPrimaryError`'s invariant structural, not conventional:
`mapGraphQLError` picks the primary entry once and passes it to every
helper, so message / classification / fields all come from the same
error entry by construction.
- Replace `endsWith("_NOT_FOUND")` / `endsWith("_CONFLICT")` with an
explicit `CODE_MAP` whitelist. `POLICY_NOT_FOUND_IN_CACHE` and similar
codes now fall through to `NetworkError` with context preserved
instead of mis-classifying as `NOT_FOUND`. Regression test added.
- Status-based canned `userMessage` strings now apply ONLY on the
`NetworkError` fallback path. Classified errors derive `userMessage`
from `primary.message ?? "An unexpected error occurred"` — never from
HTTP status — so a `ServerError { kind: "FORBIDDEN" }` returned over
HTTP 401 can no longer pick up "Run godaddy auth login" as its user
message. Addresses both wcole's "safeGraphQLUserMessage doing
redundant work" point and cblecken's "userMessage can contradict the
error class" point in one fix. Two regression tests added.
- Drop the decorative `ServerExtensions` alias — the cast wasn't a check
and the runtime guards are what enforce shape.
- Relax `ErrorField.code` to optional — match the runtime reality
enforced by the `typeof === "string"` guards.
- Align `RATE_LIMITED` envelope code to the server's wire code
`RATE_LIMIT_EXCEEDED` so agents that speak the server taxonomy don't
have to translate. (`AuthenticationError` / `AUTH_REQUIRED` alignment
is pre-DEVX-70 and tracked separately; documented inline.)
- Normalize `applications.ts` to the dominant `@/services/*` import
style.
- Test clarity: top-of-file note explaining that GraphQL conventionally
returns HTTP 200 with an `errors[]` body, so `expect(err.status).toBe(200)`
on error paths is intentional.
All 707 unit/integration tests pass; typecheck clean; biome clean.
Made-with: Cursor
Server-side mutations on app-registry-api classify failures via errors[0].extensions.code (for example VALIDATION_ERROR, AUTH_ERROR). Previously every GraphQL failure in application* services was wrapped in NetworkError, so the CLI JSON envelope reported NETWORK_ERROR with a misleading "verify environment connectivity" fix string.