Skip to content

fix(api): classify bulk handler store errors instead of calling them all 500 - #636

Merged
ericfitz merged 1 commit into
mainfrom
fix/632-bulk-500
Jul 30, 2026
Merged

fix(api): classify bulk handler store errors instead of calling them all 500#636
ericfitz merged 1 commit into
mainfrom
fix/632-bulk-500

Conversation

@ericfitz

Copy link
Copy Markdown
Owner

Fixes #632.

The bug

Bulk handlers wrapped every store error in ServerError, discarding the
classification the store had already made:

HandleRequestError(c, ServerError(fmt.Sprintf("Failed to patch threat %s", patch.ID)))

ApplyPatchOperations returns a *RequestError carrying 400 patch_failed
for a patch document the client got wrong, and StoreErrorToRequestError
preserves it via errors.As — which is why the single-resource handlers were
always correct. The bulk handlers threw it away.

Verified against a running server, same threat and same pointer:

Route Before
PATCH .../threats/{id} 400 patch_failed — "unsupported patch path: /nonexistent~field"
PATCH .../threats/bulk 500 server_error

Scope

The issue asked whether other */bulk handlers shared the cause. They do —
seven call sites across four files, which is also why the same CATS run
produced a 500 on repositories/bulk:

  • threat_sub_resource_handlers.go — bulk patch
  • asset_sub_resource_handlers.go — bulk create, bulk update
  • repository_sub_resource_handlers.go — bulk create, bulk update
  • document_sub_resource_handlers.go — bulk create, bulk update

All now route through StoreErrorToRequestError, matching the single-resource
handlers. It preserves an existing *RequestError, maps dberrors to
404/409/400, and still falls back to 500 for a genuine server fault.

Spec change this required

StoreErrorToRequestError returns ConflictError for dberrors.ErrDuplicate,
and none of the bulk operations documented 409. Without adding it, this fix
would have traded a Zero-500 violation for a documented-status-code violation.
409 (#/components/responses/Conflict) added to all seven operations, spec
revalidated, api/api.go regenerated. Version 1.6.1 → 1.6.2.

Test

TestBulkPatchThreats_ErrorClassification covers both directions, because a fix
that turned every failure into a client error would be equally wrong:

  • a client error from the store surfaces as 400 with its own code intact
  • an untyped store failure is still 500

Confirmed it is a real regression test by reverting the handler to the buggy
form — it fails there and passes on the fix.

Verification

make lint 0 issues · make build-server 1.6.2 · make test-unit 2448 passed,
0 failed · make validate-openapi 0 errors.

Not yet exercised against the cluster; that follows with the deploy.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc

…all 500

Bulk handlers wrapped every store error in ServerError, discarding the
classification the store had already made. A malformed JSON Pointer is
the clearest case: ApplyPatchOperations returns a *RequestError carrying
400 patch_failed, the single-threat PATCH surfaces exactly that, and the
bulk route turned it into 500 server_error.

Verified against a running server, same threat and same pointer:

  PATCH .../threats/{id}    -> 400 patch_failed
                               "unsupported patch path: /nonexistent~field"
  PATCH .../threats/bulk    -> 500 server_error

Seven call sites had the pattern, which is why the same run produced a
500 on repositories/bulk as well: threats (patch), assets, repositories
and documents (create and update in the upsert loops). All now route
through StoreErrorToRequestError, as the single-resource handlers
already do. It preserves an existing *RequestError, maps dberrors to
404/409/400, and still falls back to 500 for a genuine server fault.

That fallback matters: the fix must not turn every failure into a client
error. Both directions are covered by the new test, which fails on the
old code and passes on the new.

409 is added to the seven affected operations. StoreErrorToRequestError
returns ConflictError for a duplicate, and none of the bulk operations
documented 409 -- without this the fix would have traded a Zero-500
violation for a documented-status-code violation.

Version 1.6.1 -> 1.6.2.

Fixes #632

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnrVyySuyJFqcMxB8q3pNc
@ericfitz
ericfitz merged commit 2de6d9c into main Jul 30, 2026
12 checks passed
@ericfitz
ericfitz deleted the fix/632-bulk-500 branch July 30, 2026 21:53
ericfitz added a commit that referenced this pull request Jul 31, 2026
…l 500 (#639)

#636 converted the bulk PATCH and the BulkUpdate upsert loops to
StoreErrorToRequestError but left the four BulkCreate (POST .../bulk) call
sites wrapping every store error in ServerError. A duplicate id is the
clearest case: the store classifies it as dberrors.ErrDuplicate, a client
error, and the bulk create route turned it into 500 server_error.

That was the only remaining 500 in CATS run 20260730T220551Z, down from 71.

All four sites -- assets, documents, repositories and threats -- now route
through StoreErrorToRequestError, and still fall back to 500 for a genuine
server fault. Both directions are covered by a new test that fails on the
old code and passes on the new.

409 added to POST /threat_models/{threat_model_id}/threats/bulk; the other
three already documented it via #636.

Verified end-to-end on k3s running 1.6.3: all four bulk creates return 409
on duplicate ids, a unique-id create still returns 201, and the driver
error is logged without leaking SQLSTATE to the client.

Version 1.6.2 -> 1.6.3.

Fixes #638
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.

fix(api): bulk threat PATCH returns 500 on a malformed JSON Pointer instead of 400

1 participant