Skip to content

fix(api): consistent error statuses on change-request endpoints - #471

Merged
gonzalesedwin1123 merged 4 commits into
19.0from
fix/api-v2-cr-error-status-followups
Aug 28, 2026
Merged

fix(api): consistent error statuses on change-request endpoints#471
gonzalesedwin1123 merged 4 commits into
19.0from
fix/api-v2-cr-error-status-followups

Conversation

@kneckinator

@kneckinator kneckinator commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #460, implementing the "noted, not changed" items from its review. Stacked on #460 — merge that first; this diff shows its commits until it lands.

What changes

All four items are about the same thing: the change-request endpoints reporting one condition with different statuses depending on which handler it happened to flow through.

1. ValidationError → 422 on state transitions (was 409)

create() and update() map ValidationError to 422 Unprocessable Entity; the transition endpoints mapped it to 409 Conflict. $reject and $request-revision raise ValidationError for a missing reason/notes — invalid input, not a conflict. All endpoints now report 422 uniformly via _status_for_odoo_error().

This is the API-contract decision #460 explicitly deferred ("pinned by a test with a note"); that test now asserts 422.

2. MissingError → 404 (was 409)

A record that vanishes mid-transition is "not found", not "conflict". Matches the platform's global handler (fastapi/error_handlers.py maps MissingError → 404).

3. Generic detail on 403 (anti-enumeration)

An AccessError message names models, records and record rules. The 403 branch now returns a fixed "Not authorized to perform this action" instead of str(e) — the same posture as the platform handler, which returns the literal string "AccessError". Non-authorization errors still pass their text through: it is written for the actor ("Only pending change requests can be rejected").

4. create() no longer reports an authorization failure as 500

create()'s bare except Exception swallowed AccessError into a 500. An explicit except (AccessError, AccessDenied) branch now reports 403 with the generic detail — the same shadowing shape #460 fixed on the transition endpoints, one layer up.

Not attempted

Full RFC 9457 Problem Details response bodies. That is an API-wide format migration touching every router in spp_api_v2*, not a change-request fix; if wanted, it deserves its own scoped PR.

update() is also untouched: it catches only ValidationError, so an AccessError there already propagates to the platform's global handler, which reports 403 with a generic detail.

Tests

TDD — the new/changed assertions were written and observed failing first. Unit tests cover each mapping (ValidationError→422, MissingError→404, generic 403 detail, passthrough detail otherwise); route-level tests exercise $reject returning 422, the 403 body not leaking the raw message, and create() returning 403 instead of 500. Full module suite green.

Merge order

After #460. 19.0.2.0.2 → 19.0.2.0.3.

AccessError subclasses UserError in Odoo, so the change-request state
transitions — $submit, $approve, $apply and $reset — which caught
UserError and returned 409 Conflict reported permission failures as
conflicts. The client is told to resolve a conflict it cannot see, and
one that retries on 409 (reasonable for a genuine conflict, which may
clear) loops on a permission error that never will.

It is most visible on $apply now that applying requires the
change-request manager role: the endpoint's own scope check already
returns 403, so the same endpoint reported two authorization failures
with different statuses.

The mapping lives in one helper rather than a fifth copy of the same
except block, and a test fails if a handler goes back to a hard-coded
status — this is exactly the bug that returns when the next endpoint is
copy-pasted.

ValidationError has the same shape (it also subclasses UserError, so a
validation failure reports 409 where create() uses 422). Current
behaviour is pinned by a test with a note rather than changed, being a
separate API-contract decision.
… as 403 too

The same AccessError-shadowed-by-UserError bug fixed on the other four
state-transition endpoints: both handlers caught
(UserError, ValidationError) and returned a hard-coded 409, so an
authorization failure surfaced as a conflict. Both now use
_status_for_odoo_error; ValidationError still falls through to 409
unchanged, as it subclasses UserError.

AccessDenied now maps to 403 alongside AccessError, matching the
platform's global FastAPI error handler.

The source-scan guard was blind to the tuple form of the except clause,
which is exactly how the two missed handlers spelled it; it now matches
except handlers on the AST. New route-level tests exercise the mapping
through the real FastAPI handlers over HTTP.
ValidationError on a state transition now returns 422, matching what
create and update already return for the same condition; a missing
rejection reason or revision notes is invalid input, not a conflict.
MissingError returns 404 and an authorization failure on create returns
403 instead of being swallowed into a 500 by the bare except -- both
aligned with the platform's global FastAPI error handler. The 403 body
is now a fixed generic detail rather than the raw Odoo message, which
named models and record rules.
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.37%. Comparing base (3be1c39) to head (3e1892d).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #471      +/-   ##
==========================================
+ Coverage   76.13%   76.37%   +0.24%     
==========================================
  Files         661      654       -7     
  Lines       44026    44027       +1     
==========================================
+ Hits        33519    33627     +108     
+ Misses      10507    10400     -107     
Flag Coverage Δ
spp_api_v2_change_request 73.37% <100.00%> (+1.45%) ⬆️
spp_api_v2_products ?
spp_base_common 91.07% <ø> (ø)
spp_consent ?
spp_irrigation ?
spp_programs 66.97% <ø> (ø)
spp_registry 87.79% <ø> (ø)
spp_security 69.56% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...pp_api_v2_change_request/routers/change_request.py 43.36% <100.00%> (+5.73%) ⬆️

... and 74 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All four deferred items from #460's review are implemented, each verified against the diff:

  • ValidationError → 422 on transitions via _status_for_odoo_error, with the previously behaviour-documenting tests flipped to assert the new contract — and the change is a real contract change, correctly called out in HISTORY.
  • MissingError → 404: correct, and the ordering in the helper is safe (MissingError subclasses UserError, not ValidationError, so the except UserError handlers catch it and the isinstance chain resolves it before the 409 fallthrough).
  • Generic 403 detail: _detail_for_odoo_error applied on all six transition endpoints and create; the pass-through for actor-written UserError/ValidationError text is the right split, and the scope-check 403s that keep descriptive text don't leak internals.
  • create() 403: the except (AccessError, AccessDenied) branch correctly precedes the bare except Exception, and the route test pins 500 → 403.

Also checked: the AST guard from #460 stays coherent with the new create handler (it catches no UserError, so it's rightly exempt), update()'s untouched state is justified (its AccessError propagates to the platform's global 403), version 19.0.2.0.3 stacks correctly on #460's 2.0.2, and CI is fully green.

One non-blocking observation: a MissingError raised mid-transition passes Odoo's raw message through as the 404 detail, which names the model and record id (e.g. spp.change.request(42,)). The endpoint identity already reveals the model and the client sent a reference, not an id, so there's nothing meaningful to enumerate — but if you want the detail surface fully uniform, a fixed "Change request not found" in _detail_for_odoo_error's MissingError case would match the endpoint's own not-found path. Fine either way.

Merge order stands: #460 first, then this. Merge authorization is Edwin's.

#460 was squash-merged as 3be1c39 with a module tree byte-identical to
cbed615 (this branch's base for spp_api_v2_change_request), so every
conflict is squash-vs-original textual noise. Resolved by keeping this
branch's side for the module, which makes the result exactly
19.0 + this PR's own changes.
@gonzalesedwin1123
gonzalesedwin1123 merged commit 208910d into 19.0 Aug 28, 2026
20 checks passed
@gonzalesedwin1123
gonzalesedwin1123 deleted the fix/api-v2-cr-error-status-followups branch August 28, 2026 02:28
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.

2 participants