fix(api): consistent error statuses on change-request endpoints - #471
Conversation
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 Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
gonzalesedwin1123
left a comment
There was a problem hiding this comment.
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 (MissingErrorsubclassesUserError, notValidationError, so theexcept UserErrorhandlers catch it and the isinstance chain resolves it before the 409 fallthrough).- Generic 403 detail:
_detail_for_odoo_errorapplied on all six transition endpoints and create; the pass-through for actor-writtenUserError/ValidationErrortext 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 bareexcept 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.
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()andupdate()mapValidationErrorto422 Unprocessable Entity; the transition endpoints mapped it to409 Conflict.$rejectand$request-revisionraiseValidationErrorfor a missingreason/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.pymapsMissingError→ 404).3. Generic detail on 403 (anti-enumeration)
An
AccessErrormessage names models, records and record rules. The 403 branch now returns a fixed"Not authorized to perform this action"instead ofstr(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 500create()'s bareexcept ExceptionswallowedAccessErrorinto a 500. An explicitexcept (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 onlyValidationError, so anAccessErrorthere 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$rejectreturning 422, the 403 body not leaking the raw message, andcreate()returning 403 instead of 500. Full module suite green.Merge order
After #460.
19.0.2.0.2 → 19.0.2.0.3.