fix(api): stop broad handlers masking faults in the webhook/admin surface#1097
fix(api): stop broad handlers masking faults in the webhook/admin surface#1097cbcoutinho wants to merge 3 commits into
Conversation
…face Audit of 28 broad `except Exception` sites in the admin/webhook surface (Deck #679, note 390881). Fixes the three reachable bugs plus the reason none of them were diagnosable. - _sanitize_error_for_client: logger.error -> logger.exception. This helper backs every catch-all 500 in the management/access surface and the client gets only a generic message, so this log line is the sole record of what failed — and it was discarding the traceback. All 28 call sites are inside except blocks, so the traceback is always live. - create_webhook: parse the body before the try. `await request.json()` sat inside the catch-all, so malformed JSON returned 500 "Internal error" instead of 400 — telling the client the server is broken when the client sent bad JSON. A non-dict body (`[]`) hit `body.get` -> AttributeError -> also 500. delete_webhook already guarded its int() parse this way. - _register_preset_webhooks: roll back on failure. The helper returns ids only after every event registers, so a failure on event N left events 1..N-1 live in Nextcloud while the caller's store_webhook never ran — orphaned registrations, invisible to the UI, still delivering, while the handler reported "Failed to enable preset". Rollback is best-effort and never replaces the original error. - _get_enabled_presets: propagate instead of returning {}. An empty map is indistinguishable from the truthful "no presets enabled", and both callers act on the difference: the pane rendered "Not Enabled" for live presets, so Enable double-registered every event. Both callers already surface an error, so failing honestly beats answering wrongly. Every fix has a regression test verified to fail against master's code (assert 500 == 400; assert [] == [42, 43]; DID NOT RAISE; traceback was not captured). Exact CI selection green: 2387 passed. Two sites from the audit were investigated and NOT changed: access.py:172 and management.py:636 look like post-write state divergences, but their post-write steps are in-memory dict pop/del that cannot realistically raise, so the divergence is unreachable. Recorded as structural nits rather than fixed. Refs: Deck #679 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Claude finished @cbcoutinho's task in 2m 19s —— View job Review: fix(api): stop broad handlers masking faults in the webhook/admin surface
SummaryThis is a well-scoped, well-evidenced fix. I traced all four changes against their call sites and the two "investigated but not fixed" audit findings, and they hold up. Findings1. 2. 3. 4. Audit findings correctly left unfixed — I checked both by hand:
Both write-ups match the PR body's reasoning exactly — good discipline not to churn code for a non-bug. Test coverageThe five new/extended test files are strong: Minor / non-blocking observations
Verification noteI was unable to execute Overall: solid fix, correctly scoped, well-tested, and the "what we didn't fix and why" section shows real rigor rather than reflexive over-fixing. No blocking issues found. |
… raise Round-1 review follow-ups on #1097: - The three catch-all 500s in webhook_routes.py (webhook_management_pane, enable_webhook_preset, disable_webhook_preset) still used logger.error, so they discarded the traceback — the same defect this PR fixes for the _sanitize_error_for_client sites. They don't route through that helper, so the one-line fix missed them. They are in the file this PR already touches and are the same class of bug, so fix them here rather than deferring. - _get_enabled_presets: document the new contract. Now that a lookup failure raises instead of returning {}, say so in the docstring, including why, so the swallow-and-return-default pattern isn't reintroduced. Not changed: `except (ValueError, UnicodeDecodeError)` is redundant since UnicodeDecodeError subclasses ValueError, but the tuple matches existing precedent in access.py:108 and passwords.py:402 and documents which inputs are expected. Consistency with the surrounding modules wins over terseness. Exact CI selection still green: 2387 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Round-1 findings addressed in
Left as-is — Observation 1 ( On your "Not run" note — fair, and worth having a number rather than my say-so: exact CI selection One correction to your review for the record: you found 9 |
SonarCloud flagged two issues on this PR's new code (gate passed, but both are real): - python:S5713 (webhooks.py:197) — `except (ValueError, UnicodeDecodeError)` is redundant: UnicodeDecodeError subclasses ValueError. I had kept the tuple for consistency with access.py:108 / passwords.py:402, but with both the reviewer and Sonar flagging it independently, the style argument loses. Collapsed to `except ValueError` with a comment naming what it covers, since that is the non-obvious part. - python:S1192 (webhooks.py:200) — the "Bad request" literal now appears 5 times in this module; my new 400s added to it. Extracted `_BAD_REQUEST`. Behavior is unchanged: json.JSONDecodeError and UnicodeDecodeError are both ValueError subclasses, so the same inputs still return 400. Exact CI selection green: 2387 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Round-2 + SonarCloud addressed in SonarCloud flagged 2 issues on the new code (gate passed, but per CLAUDE.md these get fixed in the loop, not after):
Behavior is unchanged — verified On your two observations:
On "what I could not run" — CI has since finished: all non-review checks green, |
|



Context
Follow-up to the reviewer's observation on #1092: a broad
except Exception → return error responseswallowed a real fault inpayload_backfill.py, so its test failed for a reason unrelated to the actual error. I audited the whole admin/webhook surface (28 broad-catch sites) to see how far the pattern ran.Full inventory + evidence: Nextcloud note 390881. Card: Deck #679.
Audit tally: 12 masking / 12 too broad / 4 legitimate fail-open. Zero carried a comment justifying the breadth. Zero used
logger.exception— no traceback was emitted anywhere in this surface, including at catch-all 500s.This PR fixes the reachable bugs plus the reason none of them were diagnosable. Narrowing the merely-too-broad catches, the 401 conflation, and the 503-vs-500 mapping are left as follow-ups on #679.
Changes
1.
_sanitize_error_for_client:logger.error→logger.exception(management.py:242)This helper backs every catch-all 500 in the management/access surface, and the client deliberately receives only a generic message — so this log line is the sole record of what failed, and it was discarding the traceback. A 500 from a ~45-line try block gave no way to tell a Qdrant outage from a
TypeErrorin response building.I verified all 28 call sites are inside
exceptblocks, so the traceback is always live (logger.exceptionoutside a handler would logNoneType: None). This is a one-line change that reaches all 28.2.
create_webhook: malformed body is 400, not 500 (webhooks.py)await request.json()sat inside the catch-all, so bad JSON returned 500 "Internal error" — telling the client the server is broken when the client sent bad JSON, and burying a routine 400 in the error logs. A non-dict body ([]) hitbody.get→AttributeError→ also 500.Not a stylistic point:
delete_webhookalready guards itsint()parse exactly this way (webhooks.py:292-298), so this was an internal inconsistency, not house style.3.
_register_preset_webhooks: roll back on failure (webhook_routes.py)The helper returns ids only after every event registers. A failure on event N left events 1..N-1 live in Nextcloud while the caller's
store_webhooknever ran — orphaned registrations, invisible to the UI, still delivering — while the handler reported "Failed to enable preset", i.e. that nothing happened. Now the already-created webhooks are deleted before re-raising, so the reported end state matches reality. Rollback is best-effort and never replaces the original error (that's the one that explains the failure) — there's a test for that.4.
_get_enabled_presets: propagate instead of returning{}(webhook_routes.py){}is indistinguishable from the truthful "no presets are enabled", and both callers act on the difference:Both callers already wrap this in a handler that surfaces an error, so failing honestly is strictly better than answering wrongly. Behavior change: a transient listing failure now shows an error in the pane instead of a silently wrong state.
Two audit findings I did NOT fix (and why)
access.py:172(scope update) andmanagement.py:636(revoke) look like post-write state divergences — a durable write followed by a cache invalidation under one catch, which would report a committed write as a 500 failure. I initially flagged both as security-adjacent bugs. On checking reachability, both are false positives: the post-write steps are_scope_cache.pop(user_id, None)on a plain dict, and anawaited in-memorydelguarded by anif user_id in self._cache. Neither can realistically raise, andCancelledErroris aBaseExceptionso it isn't caught either. If the step before them raises, nothing was committed and the 500 is correct.The structure is fragile and worth tidying, but there is no live bug — recorded as structural nits on #679 rather than churned here.
Verification
Every fix has a regression test proven to fail against master's code:
test_malformed_json_returns_400_not_500assert 500 == 400test_non_object_json_body_returns_400_not_500assert 500 == 400test_register_rolls_back_already_created_webhooks_on_failureassert [] == [42, 43]test_enabled_presets_failure_propagates_...DID NOT RAISEtest_sanitizer_logs_tracebacktraceback was not capturedThe JSON tests drive a real request through a Starlette
TestClientagainst the actual handler, not a mock.uv run pytest -m unit -o "addopts=-p no:asyncio"→ 2387 passed (2379 baseline + 8 new), no regressions.sonar analyze secretsclean.Test coverage (ADR-029 gate)
This changes the behavior of
POST /api/v1/webhooks(malformed input now 400 rather than 500). Per CLAUDE.md I'm stating the coverage position explicitly rather than leaving it silent:TestClient./api/v1/*surface — includingwebhooksCRUD — is not pact-verified today; provider verification covers only the public endpoints pending the ADR-029 phase-4 Bearer-token hook. That gap is pre-existing and already tracked on Deck board 11; this PR does not widen it. The status-code change is in the direction of the HTTP contract (client fault → 4xx), so a future pact should encode 400.Refs: Deck #679
This PR was generated with the help of AI, and reviewed by a Human