Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,10 +468,10 @@ With path-based addressing, the agent would need to re-fetch between every step.
Run all suites locally:

```bash
# TypeScript (Vitest) — 257 tests
# TypeScript (Vitest): 885 tests
npm test

# PHP (PHPUnit, stub WP bootstrap) — 335 tests
# PHP (PHPUnit, stub WP bootstrap): 1,440 tests
cd wordpress-plugin/gk-block-mcp && phpunit -c tests/phpunit.xml
```

Expand Down Expand Up @@ -616,6 +616,17 @@ Every REST endpoint returns errors as JSON in the standard WordPress shape `{ co
| `rate_limit_exceeded` | Per-post write budget exhausted (10 writes/min, or 2 full-rewrites/min) | Wait up to 60 s and retry; consider batching with `update_blocks` |
| `scan_rate_limited` | Settings-page scan triggered too frequently | Wait; this affects admin-side scans only |

### Method not allowed (HTTP 405)

Not a plugin error: a 405 comes from the host's firewall or web server, ahead of WordPress. Some managed hosts reject `PUT`, `PATCH`, and `DELETE` outright, which is why reads and `create_post` succeed on such a host while every editing tool fails.

The client handles this on its own. When one of those verbs is rejected, it replays the request as a `POST` carrying an `X-HTTP-Method-Override` header (the form WordPress core accepts), and remembers the host, so later edits go through on the first attempt. Hosts that accept the real verbs never see the header.

| Symptom | What it means | How to recover |
|---|---|---|
| `Block API Error (405)` with an HTML body (e.g. `nginx`) on an editing tool | The firewall rejected both the real verb and the override replay | Ask the host to allow `PUT`, `PATCH`, and `DELETE`, or to stop stripping `X-HTTP-Method-Override`, on the WordPress REST path |
| Reads work, edits fail immediately after install | The host rejects editing verbs; the fallback could not complete | Same as above; confirm with `curl -X PATCH` against `/wp-json/gk-block-api/v1/...` |

### Upstream (HTTP 502)

| Code | When it fires | How to recover |
Expand Down
47 changes: 47 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ const MAX_RETRIES = 2;
*/
const IDEMPOTENT_METHODS = new Set(['get', 'head', 'options']);

/**
* Verbs that some hosts reject at the edge before the request reaches PHP.
*
* The plugin registers its editing routes as literal `PUT` / `PATCH` / `DELETE`
* rather than the `EDITABLE` alias, so a plain POST does not match them — the
* `X-HTTP-Method-Override` header is what carries the intended verb through.
*/
const METHOD_OVERRIDE_VERBS = new Set(['put', 'patch', 'delete']);

/** Sleep for `ms` milliseconds. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
Expand Down Expand Up @@ -175,6 +184,18 @@ interface PageBlocksResponse {
export class WordPressBlockClient {
private client: AxiosInstance;

/**
* Set once a host proves it rejects PUT / PATCH / DELETE at the edge.
*
* Some managed hosts front WordPress with a WAF that answers those verbs with
* a 405 before the request reaches PHP, which breaks every editing tool while
* GET and POST keep working. WordPress core honours `X-HTTP-Method-Override`
* on a POST, so a rejected request is replayed in that shape. The flag makes
* the fallback sticky for the life of the client: only the first write pays
* for the rejected round-trip.
*/
private useMethodOverride = false;

/**
* Create a new WordPress Block API client.
*
Expand Down Expand Up @@ -216,13 +237,39 @@ export class WordPressBlockClient {
timeout: 30000,
});

// Request interceptor: once a host is known to reject real verbs, send the
// intended method as an override header on a POST instead.
this.client.interceptors.request.use((config) => {
const method = (config.method ?? 'get').toLowerCase();
const needsOverride = this.useMethodOverride && METHOD_OVERRIDE_VERBS.has(method);
if (needsOverride) {
config.headers.set('X-HTTP-Method-Override', method.toUpperCase());
config.method = 'post';
}
return config;
});

// Response interceptor: retry transient errors with exponential backoff,
// then format any final error so it carries wpCode/wpData/wpStatus for
// the server-level catch in src/index.ts.
this.client.interceptors.response.use(
(r) => r,
async (error: AxiosError) => {
const config = error.config as (AxiosRequestConfig & { __retryCount?: number }) | undefined;

// A 405 on a real verb comes from the edge, not WordPress: the plugin
// answers these paths. Replay every one, not just the first: concurrent
// writes all go out as real verbs, so each needs its own replay or it
// fails spuriously. A replay arrives here as `post`, which is not an
// override verb, so a rejected replay surfaces instead of looping.
const method = (config?.method ?? 'get').toLowerCase();
const edgeRejectedVerb =
error.response?.status === 405 && METHOD_OVERRIDE_VERBS.has(method);
if (config && edgeRejectedVerb) {
this.useMethodOverride = true;
return this.client.request(config);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (config && isRetryable(error)) {
const attempt = (config.__retryCount ?? 0) + 1;
if (attempt <= MAX_RETRIES) {
Expand Down
Loading
Loading