Skip to content

fix(control-plane): surface upstream errors via respondWithSdk helper#1678

Open
offendingcommit wants to merge 3 commits into
vectorize-io:mainfrom
offendingcommit:claude/fix-control-plane-sdk-error-handling
Open

fix(control-plane): surface upstream errors via respondWithSdk helper#1678
offendingcommit wants to merge 3 commits into
vectorize-io:mainfrom
offendingcommit:claude/fix-control-plane-sdk-error-handling

Conversation

@offendingcommit
Copy link
Copy Markdown
Contributor

Closes #1677.

Problem

Every SDK-backed route handler in hindsight-control-plane had the same shape:

try {
  const response = await sdk.someMethod({ ... });
  return NextResponse.json(response.data, { status: 200 });
} catch (error) {
  console.error("Error fetching X:", error);
  return NextResponse.json({ error: "Failed to fetch X" }, { status: 500 });
}

The SDK (@hey-api/client-fetch) returns {data, error, response} and does not throw on non-2xx. When the upstream API 5xx'd, response.data was undefined, and Node's Response.json(undefined) throws TypeError: Value is not JSON serializable. The catch block logged that TypeError as if it were the actual failure, hiding the real upstream error, and hard-coding the response status to 500 regardless of whether upstream said 503, 429, 502, etc.

Fix

New helper src/lib/sdk-response.ts::respondWithSdk(result, label, status?):

  • Detects result.error !== undefined || result.data === undefined
  • Logs the upstream status + upstream error detail
  • Returns a NextResponse with the upstream status code (502 fallback for network-level failures with no Response object)
  • Body shape on failure: {error: <label>, upstream: {status, detail}} — dashboards/clients now have the real upstream message
  • On success, serializes result.data with the requested status (default 200; pass 201 for create endpoints)

17 SDK-backed routes refactored to use it. Routes that parse a request body keep a minimal try/catch around await request.json() and now return 400 on malformed JSON (a small UX improvement over the prior 500).

Out of scope (intentional)

  • documents/[documentId]/route.ts PATCH and banks/[bankId]/operations/[operationId]/route.ts POST use raw fetch() rather than the SDK — they don't exhibit the bug and are unchanged.
  • banks/[bankId]/observations/route.ts does post-fetch transformation of response.data.items and doesn't cleanly compose with the helper without restructuring — left for a separate PR.

Tests

hindsight-control-plane/src/lib/sdk-response.test.ts — 12 vitest cases:

  • Success: returns 200 with data, honors custom status (201), array data, empty-object data
  • Failure: passes upstream 500/503/429 through, includes upstream.detail in body, logs to console.error, falls back to 502 when no Response object (network-level), treats undefined data + undefined error as failure rather than 200ing with null
  • Regression assertion: expect(() => respondWithSdk(failingResult, label)).not.toThrow() — directly guards the Response.json(undefined) TypeError that started this whole thread
 Test Files  1 passed (1)
      Tests  12 passed (12)

CI

Wired npm test --workspace=hindsight-control-plane into the existing build-control-plane and build-hindsight-all jobs in .github/workflows/test.yml. Tests run before the build step, so a regression breaks CI fast.

Verification

# tests pass
cd hindsight-control-plane && npm test
# 12/12 green in ~150ms

# production build still succeeds
npm install --workspace=hindsight-clients/typescript
npm run build --workspace=hindsight-clients/typescript
cd hindsight-control-plane && npm run build
# next build + standalone bundle: success, all 25 routes compile

Backwards compatibility

  • Browser happy path: unchanged. Dashboard receives the same response.data.
  • Browser failure path: response body grows a upstream field but keeps the same top-level error string, and the HTTP status code can now be the real upstream code (503, 429, 502) instead of always 500. Clients keying off literal status 500 would notice; the dashboard's fetchApi already toasts on any 4xx/5xx so no UI changes are needed.
  • Operator logs: log line is "<label>:" with {upstreamStatus, upstreamError} payload instead of an arbitrary Error object. Materially clearer.

offendingcommit and others added 3 commits May 20, 2026 14:10
Pre-commit hook auto-sync caught drift between hindsight-docs/ sources
and the skills/hindsight-docs/ mirror. No content authored here.
Closes vectorize-io#1677.

The SDK (@hey-api/client-fetch shape) returns `{data, error, response}` and
does not throw on non-2xx upstream responses. Route handlers were doing
`NextResponse.json(response.data, {status: 200})` without checking
`response.error` first. When the upstream API 5xx'd, `response.data` was
`undefined`, and Node's spec'd `Response.json(undefined)` threw
`TypeError: Value is not JSON serializable`. The catch block logged that
TypeError as if it were the failure, masking the real upstream error and
hard-coding the response status to 500.

Introduce `src/lib/sdk-response.ts::respondWithSdk(result, label, status?)`
that:

- Detects `result.error !== undefined || result.data === undefined`
- Logs the upstream HTTP status + upstream error detail
- Returns a NextResponse with the upstream status code (502 fallback when
  the SDK had no Response object — i.e. network-level failure)
- Surfaces the upstream detail in the body as `{error, upstream: {status,
  detail}}` so the dashboard can show a useful message
- On success, serializes `result.data` with the requested status (default
  200; pass 201 for create endpoints)

Refactor 17 SDK-backed route files to use the helper. Routes that parse a
request body keep a minimal try/catch around `await request.json()` and
return 400 on malformed JSON (a small UX improvement over the prior 500).
Routes that use raw `fetch()` (documents PATCH, operations retry POST) and
the observations route (which does post-fetch transformation of
`response.data.items`) are left untouched — they don't exhibit the bug.

Add vitest + 12 durable tests covering the helper (success path with
custom status, failure pass-through for 500/503/429, body shape includes
`upstream.detail`, regression assertion that NO TypeError escapes when
data is undefined, default-502 for network-level failures with no
Response object).

Wire `npm test --workspace=hindsight-control-plane` into the existing
`build-control-plane` and `build-hindsight-all` CI jobs so the helper
stays load-bearing.

Browser UX is unchanged on the happy path. On failures, operators now see
the real upstream status code and error body in both logs and the
response.
@benfrank241 benfrank241 requested a review from nicoloboschi May 22, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

control-plane: SDK error responses logged as 'Value is not JSON serializable', lose upstream status + detail

2 participants