Skip to content

fix(rest): idempotent, per-controller derived permission_callback#39

Merged
alexstandiford merged 1 commit into
mainfrom
fix/idempotent-permission-callback
Jun 12, 2026
Merged

fix(rest): idempotent, per-controller derived permission_callback#39
alexstandiford merged 1 commit into
mainfrom
fix/idempotent-permission-callback

Conversation

@alexstandiford

Copy link
Copy Markdown
Contributor

WordPress invokes permission callbacks repeatedly per request (dispatch + rest_send_allow_header for every method on the route, same WP_REST_Request). 4.3.1's checkPermissions re-ran the middleware chain each time; non-idempotent middleware (ConvertCsvMiddleware) threw a TypeError on the second pass that catch (Exception) missed, fataling the request after the callback had produced a correct response — every middleware-bearing GET endpoint returned 200 with an empty body. Caught by Siren's WordPress E2E suite (20 failures) on the 4.3.1 bump.

  • Memoize outcomes per (request, controller class); repeat checks return the stored answer without re-running
  • Per-controller keying so the Allow-header pass over the POST controller can't clobber the GET controller's outcome
  • catch \Throwable → 500 WP_Error instead of a fatal

67 tests pass, 4 new regressions.

…ller

WordPress invokes a route's permission callbacks more than once per
request: once at dispatch, then again for EVERY method registered on the
matched route while rest_send_allow_header builds the Allow header — all
against the same WP_REST_Request instance.

checkPermissions() re-ran the controller middleware chain on every
invocation. Middleware is not idempotent (ConvertCsvMiddleware converts
a CSV param to an array in place), so the second pass threw a TypeError
(explode() on an array), which catch(Exception) did not catch — fataling
the request after the route callback had already produced a correct
response. Clients saw HTTP 200 with an empty body on every middleware-
bearing GET endpoint.

- Memoize middleware outcomes per (request, controller class) and return
  the stored answer on repeat invocations without re-running the chain.
  Per-controller keying matters: the Allow-header pass checks the POST
  controller against a GET-shaped request, and its validation failure
  must not clobber the GET controller's passing outcome.
- Store auth WP_Errors in the same map so repeated checks return the
  same rejection without re-running auth middleware.
- Catch \Throwable, not Exception: middleware engine errors surface as
  a 500 WP_Error instead of fataling the request (wrapped for
  LoggerStrategy::logException(), which takes Exception).

Four new regression tests pin: single run across repeated checks,
memoized auth rejection, per-controller outcome isolation, and
TypeError-to-WP_Error conversion.
@alexstandiford alexstandiford merged commit 545aeb9 into main Jun 12, 2026
1 check passed
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.

1 participant