Skip to content

fix(client): fall back to X-HTTP-Method-Override when a host 405s PUT/PATCH/DELETE - #67

Merged
zackkatz merged 3 commits into
developfrom
fix/rest-method-override-waf
Jul 24, 2026
Merged

fix(client): fall back to X-HTTP-Method-Override when a host 405s PUT/PATCH/DELETE#67
zackkatz merged 3 commits into
developfrom
fix/rest-method-override-waf

Conversation

@zackkatz

@zackkatz zackkatz commented Jul 24, 2026

Copy link
Copy Markdown
Member

Fixes BLOCK-41.

The bug

Every editing tool fails against gravitykit.com (Convesio) with an opaque Block API Error (405) carrying an nginx HTML body, while reads and create_post keep working. That asymmetry makes it read like a permissions or tool problem rather than a transport one. Eight call sites are affected: update_block, update_blocks, delete_block, rewrite_post_blocks, update_post, edit_block_tree, yoast_update_seo, yoast_bulk_update_seo.

Root cause

Convesio's WAF rejects PUT / PATCH / DELETE on the ?rest_route= form, before the request reaches PHP. The same verb against the pretty /wp-json/ path is fine:

Request Result
PUT /wp-json/gk-block-api/v1/posts/{id}/blocks 400 (reached WP: "missing param blocks")
PUT /?rest_route=/gk-block-api/v1/posts/{id}/blocks 405 (nginx HTML)
OPTIONS on the route allow: GET, POST, PUT
POST + X-HTTP-Method-Override: PUT via ?rest_route= 400 (reached WP)
POST + X-HTTP-Method-Override: DELETE via ?rest_route= 200

src/client.ts builds every request on ?rest_route= via restRouteUrl(), which exists deliberately so tool calls don't 404 on plain-permalink sites. GET and POST pass the WAF, which is exactly why reads and creates kept working.

The fix

Fall back to POST + X-HTTP-Method-Override when one of those verbs returns 405, cached per client instance so later writes take a single round-trip.

  • Adaptive, not blanket. Hosts that accept the real verbs never see an override header, so the change is inert where it isn't needed. It also covers the inverse edge case (a host that strips the override header but allows real verbs).
  • A plain POST would not work. The editing routes are registered as literal PUT / PATCH / DELETE rather than the EDITABLE alias, so POST alone does not match them. The override header carries the intended verb.
  • Pretty permalinks were rejected as the fix. ?rest_route= exists to survive plain-permalink sites; switching would trade this bug for the 404 bug it prevents.
  • Cannot loop. The replay carries method POST, not an override verb, so a second 405 surfaces after exactly two attempts.

Verification

  • 16 Vitest cases against loopback servers reproducing both host shapes. Reverting src/client.ts fails 11 of 16; the 5 that still pass are the ones asserting the fallback stays dormant (3 permissive-host, 2 scope guards).
  • Edge cases pinned: body, query params, and Basic auth survive the replay; no engagement on a 405 for POST or on a non-405 failure for PATCH (so a real 403 is never masked); bounded failure when the replay is rejected too; the override survives a 429 backoff retry.
  • Full suites green: 885 Vitest, 1,440 PHPUnit.
  • Live against production: update_post on a docs post returned 405 before and succeeds after.

Also in this PR

  • README.md: new 405 section under Error Codes (the status comes from the host's firewall, not the plugin, and the client now handles it), plus corrected test counts (the stated 257/335 were stale).
  • readme.txt: changelog entry under = develop =.
  • Rebuilt assets/mcp-server/index.cjs, since CI does not rebuild the server bundle.

https://claude.ai/code/session_01Njh4D63XnZhsJHMbEU7vYq

Summary by CodeRabbit

  • New Features

    • Automatically retries edit requests that get 405 Not Allowed by sending POST with an X-HTTP-Method-Override header for intended PUT/PATCH/DELETE verbs.
    • Learning is scoped per client instance, preserving URL (including query), body, headers, and avoiding retry loops; direct verbs are used when supported.
  • Documentation

    • Added troubleshooting guidance for 405 responses from host/firewall behavior and how recovery works.
    • Updated local test instructions and counts; added an unreleased changelog entry.
  • Tests

    • Added comprehensive coverage for the override fallback, edge cases, and retry/backoff interactions.

💾 Build file (46b1c6e).

zackkatz added 2 commits July 23, 2026 19:44
…/PATCH/DELETE

Some managed hosts (Convesio among them) front WordPress with a WAF that
answers PUT, PATCH, and DELETE with a bare nginx 405 before the request
reaches PHP. GET and POST pass, so reads and create_post kept working while
every editing tool failed: update_block, update_blocks, delete_block,
rewrite_post_blocks, update_post, and the two Yoast writers — eight call
sites in all.

The `?rest_route=` form is what the WAF singles out: the same PUT against
the pretty `/wp-json/` path reaches WordPress fine. Switching to pretty
permalinks was rejected because `?rest_route=` exists precisely so tool
calls don't 404 on plain-permalink sites (see src/rest-url.ts).

WordPress core honours `X-HTTP-Method-Override` on a POST, so a rejected
request is replayed in that shape and the host is remembered for the life of
the client, leaving later writes a single round-trip. The fallback is
adaptive rather than blanket: hosts that accept the real verbs never see an
override header, so the change is inert everywhere it isn't needed.

The editing routes are registered as literal PUT / PATCH / DELETE rather
than the EDITABLE alias, so a plain POST would not match them; the override
header is what carries the intended verb through.

Verified against production: update_post on a docs post returned 405 before
and succeeds after.

Claude-Session: https://claude.ai/code/session_01Njh4D63XnZhsJHMbEU7vYq
…EADME

Broadens the fallback coverage from 6 cases to 16. New cases pin the parts a
naive replay gets wrong: the request body, query parameters, and Basic auth all
have to survive the replay, or a write silently lands empty or unauthenticated.

Also pins the boundaries. The fallback must not engage for a 405 on POST (not an
override verb) or for a non-405 failure on PATCH, since masking a real 403 behind
a replay would hide a permission problem. When a host rejects the replay too, the
error surfaces after exactly two attempts rather than looping, and the override
survives a 429 backoff retry so the two interceptors compose.

Reverting src/client.ts fails 11 of the 16; the 5 that still pass are the ones
asserting the fallback stays dormant (three permissive-host cases, two scope
guards), which is the signature we want.

README: adds a 405 section to Error Codes explaining that the status comes from
the host's firewall rather than the plugin, that the client replays such
requests itself, and what to ask the host for when even the replay is rejected.
Test counts were stale (257/335); verified counts are 885 Vitest and 1,440
PHPUnit.

Claude-Session: https://claude.ai/code/session_01Njh4D63XnZhsJHMbEU7vYq
@linear-code

linear-code Bot commented Jul 24, 2026

Copy link
Copy Markdown

BLOCK-41

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The client detects edge HTTP 405 responses for PUT, PATCH, and DELETE, then retries using POST with X-HTTP-Method-Override. The fallback remains enabled per client instance. Tests cover replay behavior, request preservation, retry boundaries, and permissive hosts. Documentation and release notes describe the behavior.

Changes

Method Override Fallback

Layer / File(s) Summary
Method override client flow
src/client.ts, wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs
Adds eligible-verb constants, sticky fallback state, request rewriting, and replay after HTTP 405 responses.
Fallback behavior validation
tests/client-method-override.test.ts
Tests rejected and permissive hosts, replayed request data, client-specific activation, non-405 errors, repeated 405 responses, and transient retries.
Documentation and release notes
README.md, wordpress-plugin/gk-block-mcp/readme.txt
Updates test counts and documents HTTP 405 causes, recovery steps, and the method-override changelog entry.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client as WordPressBlockClient
  participant Host as Edge host
  participant API as WordPress API
  Client->>Host: Send PUT/PATCH/DELETE request
  Host-->>Client: Return HTTP 405
  Client->>Host: Replay as POST with X-HTTP-Method-Override
  Host->>API: Forward overridden method
  API-->>Client: Return response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: falling back to X-HTTP-Method-Override after 405s on write verbs.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/rest-method-override-waf

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client.ts`:
- Around line 260-269: Update the retry condition in the request error handling
around edgeRejectedVerb so every rejected original PUT/PATCH/DELETE request is
replayed, rather than gating replay on !this.useMethodOverride. Preserve
METHOD_OVERRIDE_VERBS.has(method) as the loop-prevention check, and add a
concurrent parallel-write regression case where both original requests receive
405 responses and are replayed successfully.

In `@wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs`:
- Around line 40163-40167: Update the retry condition in the request handling
branch around edgeRejectedVerb so every eligible original-method 405 with
config3 is replayed, even when useMethodOverride is already enabled; keep
enabling the flag only when needed, and return client.request(config3) for all
such responses. The replay uses POST and must not recurse through this
condition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1fc336e3-f360-442f-ae64-9f07bc7f3350

📥 Commits

Reviewing files that changed from the base of the PR and between 6da6faf and 4d071c3.

📒 Files selected for processing (5)
  • README.md
  • src/client.ts
  • tests/client-method-override.test.ts
  • wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs
  • wordpress-plugin/gk-block-mcp/readme.txt

Comment thread src/client.ts
Comment thread wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs
Concurrent writes all go out as real verbs, because none of them has seen a 405
yet. Gating the replay on `!useMethodOverride` meant the first rejection set the
flag and replayed while the rest fell through, surfacing a 405 the caller could
do nothing about.

The flag was never what prevented recursion: a replay carries method `post`,
which is not an override verb, so it cannot re-enter the branch. Requests issued
after the flag is set are converted by the request interceptor and likewise
arrive as `post`. Dropping the guard therefore only affects the race window.

Caught by CodeRabbit on #67. The added test fails against the guarded client and
passes here.

Claude-Session: https://claude.ai/code/session_01Njh4D63XnZhsJHMbEU7vYq
@zackkatz

Copy link
Copy Markdown
Member Author

Good catch on the concurrency race, fixed in 46b1c6e.

You're right that the flag was never what prevented recursion. A replay carries method post, which isn't in METHOD_OVERRIDE_VERBS, so it can't re-enter the branch; and requests issued after the flag is set are converted by the request interceptor and likewise arrive as post. So the !useMethodOverride guard only ever suppressed the race window, which is exactly the case that needs the replay.

Dropped the guard and added a regression test that fires three concurrent writes: it fails against the guarded client (one rejects with a 405) and passes now, with all three replayed.

@zackkatz
zackkatz merged commit b13bfee into develop Jul 24, 2026
9 checks passed
@zackkatz
zackkatz deleted the fix/rest-method-override-waf branch July 24, 2026 00:54
@coderabbitai coderabbitai Bot mentioned this pull request Jul 24, 2026
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