Skip to main content

3 posts tagged with "REST API"

View all tags

WordPress PDF Download 500? Vendor, REST Context, tempDir

Β· 5 min read

Clicking the PDF catalog download on a product page first produces WordPress's "There has been a critical error on this website"; after the missing-library problem is fixed, the same endpoint switches to HTTP 500 β€” three independent pits, stacked into one failure chain.

Ran into this while building CCLEE B2B, a WooCommerce B2B enhancement plugin covering customer group management, tiered pricing, and bulk ordering β€” the PDF catalog feature walked through all three.

TL;DR​

PitRoot CauseFix
Critical errorvendor never entered git, rsync deployed without libraries.gitignore exception + force-commit vendor
REST endpoint 500admin-only functions absent in REST contextfunction_exists guard + tempnam() fallback
Still 500 after fixesmPDF's default temp dir unwritable in containerpoint tempDir at sys_get_temp_dir()

All three share one backdrop: a CI + rsync containerized deployment pipeline. None of them reproduces on a standard LAMP host β€” they are almost pure products of the container-plus-REST combination.

Pit 1: "There has been a critical error" β€” vendor never reached the server​

Symptom: everything works locally; after deployment, clicking download yields WordPress's generic fatal error page.

Root cause: vendor/ (the mPDF library) never reached the server. The chain is doubled: the plugin's own .gitignore excludes /vendor/, and the repo root .gitignore carries both vendor/ and plugins/ rules, masking tracked files at the top level. CI checkout skips untracked files, rsync faithfully mirrors a library-less directory, and on the server require_once vendor/autoload.php fatals immediately.

Fix: remove /vendor/ from the plugin .gitignore, add an exception at the repo root, then force-commit:

git add -f wp/plugins/cclee-b2b/vendor/

When a WordPress plugin ships via CI + rsync, the server never runs composer install β€” vendor must go into git as part of the source.

Pit 2: wp eval Works, REST Endpoint 500 β€” Admin Functions Missing​

Symptom: with vendor in place, calling the PDF generation method through wp eval works; the REST endpoint /wp-json/cclee-b2b/v1/pdf-catalog returns 500.

Root cause: two calls incompatible with the REST context. wp_tempnam() is defined in wp-admin/includes/file.php β€” REST requests never load that directory, so the call fatals. WC()->shop_management is not a public WooCommerce property either, and the fallback path fatals the same way. wp eval avoids all of this precisely because the CLI loads full WordPress including admin files.

Fix: guard admin functions with an existence check and fall back to PHP native:

$tmp = function_exists( 'wp_tempnam' )
? wp_tempnam( $filename )
: tempnam( sys_get_temp_dir(), 'pdf' );

The shop name moves to the public API: get_bloginfo( 'name' ). Inside REST, treat everything under wp-admin/includes/ as nonexistent.

Pit 3: Dependencies in Place, Still 500 β€” mPDF Temp Dir Unwritable​

Symptom: the class loads, no more fatals, yet REST still returns 500. The server access log keeps a single 858-byte response.

Root cause: mPDF defaults to vendor/mpdf/mpdf/tmp/ as its temp directory; rsync preserves the source owner UID 1001 while PHP-FPM inside the container runs as www-data (UID 82). The directory is unwritable and the mPDF constructor throws. Attempting chown inside the container fails too β€” the volume-mounted filesystem doesn't allow it.

Fix: point the temp directory at the system /tmp explicitly:

$mpdf_tmp = sys_get_temp_dir() . '/mpdf';
if ( ! is_dir( $mpdf_tmp ) ) {
wp_mkdir_p( $mpdf_tmp );
}

$mpdf = new \Mpdf\Mpdf( array(
'mode' => 'utf-8',
'format' => 'A4',
'tempDir' => $mpdf_tmp,
) );

There is a shortcut for diagnosing this class of 500: wp eval-file calls the internal method directly, bypassing the REST route and permission checks, and the exception stack arrives in one step. Silent skips of this kind are not unique to PDF generation β€” PPCP buttons silently skipping render is another example.

Caution

Verify the three fixes in order: vendor lands first, then test the REST context, and only then the temp directory β€” skipping steps misattributes which fix killed the 500. Also, the function_exists guard is not a long-term shape; if the plugin claims to run outside admin contexts, its temp-file logic should never touch wp-admin/includes/ at all.

FAQ​

Why does wp eval work while the REST endpoint returns 500?​

The CLI environment loads full WordPress including wp-admin/includes/, while REST requests never load that directory. Code depending on admin-only functions like wp_tempnam runs fine under wp eval and Fatal Errors inside REST β€” the two contexts load different file sets.

How do I find the actual exception behind a REST 500?​

Call the plugin's internal method directly with wp eval-file, bypassing the REST route and permission checks β€” the exception stack arrives in one step, far more useful than an 858-byte 500 page.

Should a containerized WordPress plugin deployment run composer install on the server?​

Don't count on it β€” in a CI plus rsync pipeline the server never runs composer install. The vendor directory must be committed to git and land on the server through rsync; a plugin .gitignore that excludes /vendor/ ships a broken plugin.

Why is a library's built-in temp dir unwritable inside a container?​

rsync preserves the source machine's owner UID (say 1001) while PHP-FPM inside the container runs as another identity (www-data, UID 82), so the default temp dir under vendor/ becomes unwritable β€” and volume-mounted filesystems often refuse chown. Point tempDir at sys_get_temp_dir() explicitly.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Airflow dagRun Trigger Fails Silently? The logical_date Unique Constraint

Β· 5 min read

While triggering the same DAG repeatedly via the Airflow REST API for a staged rollout check, the request came back 4xx with no dag_run_id in the body β€” the DAG never actually ran β€” yet the script treated it as success.

Encountered this while building AI Analytics β€” LLM-powered analytics that surfaces market trends, user behavior, and sales data for precise operations strategy. The staged rollout of the ad-decision pipeline needed to trigger the same analysis repeatedly on Airflow for comparison, and some triggers were failing silently.

TL;DR​

Airflow enforces a unique constraint on each DAG's logical_date (dag_run_id must also be unique). POSTing /dags/{dag_id}/dagRuns with a logical_date that already exists gets rejected with a 4xx, and the response body contains no dag_run_id. If you only check the HTTP status code and don't inspect the returned dag_run_id, you'll mistake the rejection for success. Fix: use a distinct logical_date (and dag_run_id) on every trigger.

Symptoms​

To run a comparison test, the same DAG was triggered repeatedly with a fixed date 2026-01-01:

$ curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dag_run_id": "manual-run-1",
"logical_date": "2026-01-01T00:00:00Z"
}'
# First time: returns a normal dag_run object with dag_run_id βœ…

$ curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dag_run_id": "manual-run-2",
"logical_date": "2026-01-01T00:00:00Z" # ⚠️ same logical_date
}'
# Second time: returns an error object, no dag_run_id ❌
{
"detail": "...",
"status": 400,
"title": "Bad Request",
"type": "https://airflow.apache.org/docs/apache-airflow/2/stable-rest-api-ref.html#/default/Error"
}

If the caller only checks "is it 2xx" and stops there, or parses the JSON without verifying that dag_run_id exists, the second failure is silently swallowed β€” no error in the logs, no run in the Airflow UI.

Root Cause​

Airflow uses dag_run_id as the primary key for each run and maintains uniqueness on (dag_id, logical_date) in the metadata DB's dag_run table. logical_date is the "logical time" of a run β€” the scheduler uses it to decide whether a given schedule slot has already executed. Once a run with some logical_date exists for a DAG, triggering again with the same value is rejected to prevent duplicate execution.

The catch is that this failure is a 4xx with an error JSON, not a connection error or a 5xx. Many scripts only do a coarse response.status_code == 200 check, or grab the JSON and read fields without verifying dag_run_id is present β€” so "creation rejected" reads as "creation succeeded".

Solution​

Core idea: use a distinct logical_date (and dag_run_id) on every trigger. For replay / rollout-comparison scenarios, just append a counter to the date:

# Each iteration uses a different logical_date (2026-01-01 / 02 / 03 …)
for i in 1 2 3; do
curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"dag_run_id\": \"manual-run-$i\",
\"logical_date\": \"2026-01-0${i}T00:00:00Z\"
}"
done

Even better, use an incrementing timestamp so logical_date and dag_run_id never collide. More importantly: always verify the dag_run_id field in the response β€” treat it as the only proof the trigger actually succeeded:

import requests

def trigger_dag(dag_id: str, logical_date: str, conf: dict | None = None) -> str:
resp = requests.post(
f"{AIRFLOW}/api/v2/dags/{dag_id}/dagRuns",
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
json={"dag_run_id": f"manual-{logical_date}", "logical_date": logical_date, "conf": conf or {}},
)
# ❌ Not enough: status-only check lets 4xx slip through as success
# resp.raise_for_status()
data = resp.json()
# βœ… Correct: only count it as created if dag_run_id is present
if "dag_run_id" not in data:
raise RuntimeError(f"Trigger failed: {resp.status_code} {data}")
return data["dag_run_id"]

# A distinct logical_date each time makes repeated triggers safe
for i in range(1, 4):
trigger_dag("my_dag", f"2026-01-0{i}T00:00:00Z")

Keep dag_run_id unique too β€” it's the primary key, and duplicates are rejected outright. A "prefix + logical_date" convention is common: unique, and easy to spot in the UI.

FAQ​

How do you trigger a DAG with the Airflow REST API?​

POST /api/v2/dags/{dag_id}/dagRuns with a body containing at least dag_run_id and logical_date (plus an optional conf for parameters). Both must be unique within the same DAG, or Airflow returns 4xx. In code, prefer the TriggerDagRunOperator, which also generates a unique run id internally.

Why does triggering the same DAG repeatedly fail in Airflow?​

Because Airflow maintains a unique constraint on (dag_id, logical_date) in the dag_run table, and dag_run_id itself is a primary key. Duplicate logical_date or dag_run_id values are rejected with 4xx. For replays or staged comparisons, give each trigger a fresh logical_date (or incrementing timestamp).

Caveats

  • Verify dag_run_id, not just the status code: a 4xx with an error JSON is Airflow's normal way of saying "creation rejected" β€” a status-only check easily misreads failure as success.
  • Use a past logical_date: a future date is treated as a scheduled run and won't execute immediately; use a past date to run it now.
  • API version differences: Airflow 2.x uses /api/v2/dags/{dag_id}/dagRuns; 3.x adjusts paths and fields β€” always check the REST API reference for your version when upgrading.
  • Prefer the CLI's --logical-date for replays: airflow dags trigger accepts a date, but the logical_date uniqueness constraint still applies β€” a repeated date fails the same way.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

WordPress REST API Image Upload Returns 405? Check Your Hostinger CDN

Β· 4 min read

While building a WooCommerce product import tool for a client, POST /wp-json/wp/v2/media would succeed for the first few images, then suddenly return 405 Not Allowed for all subsequent requests.

TL;DR​

Hostinger CDN (hcdn) blocks POST /wp-json/wp/v2/media requests by default. The response headers server: hcdn and x-hcdn-request-id are the smoking gun. Disable CDN or contact Hostinger support to whitelist /wp-json/* POST requests.

The Problem​

Uploading images to WordPress Media Library via REST API:

curl -X POST 'https://example.com/wp-json/wp/v2/media' \
-u 'user:app_password' \
-H 'Content-Disposition: attachment; filename="product-01.jpg"' \
-H 'Content-Type: image/jpeg' \
--data-binary @image.jpg

The first 2-4 images return 201 Created, then all subsequent requests fail with:

<html>
<head><title>405 Not Allowed</title></head>
<body>
<center><h1>405 Not Allowed</h1></center>
<hr><center>nginx</center>
</body>
</html>

This "partial success" pattern is misleading β€” it looks like rate limiting, but the real cause is entirely different.

Root Cause​

Using curl -v to inspect the full response headers revealed:

< HTTP/2 405
< server: hcdn
< x-hcdn-request-id: cfc5ad1198938cd9f1e02ce71ed0ae61-kul-edge1

Key findings:

  • server: hcdn β€” This is Hostinger's custom CDN (hcdn), not the origin nginx server
  • x-hcdn-request-id β€” CDN edge node ID (kul-edge1 = Kuala Lumpur), confirming the request was blocked at the CDN layer before reaching WordPress

Hostinger CDN's default security rules block POST requests to /wp-json/wp/v2/media. The initial successes were likely due to CDN rule cold-start or cache misses.

Solution​

Option 1: Disable CDN (Quick Fix)​

Go to Hostinger hPanel β†’ Website β†’ CDN β†’ Disable.

This takes effect immediately but removes CDN acceleration. Suitable for staging environments or emergency fixes.

Submit a support ticket requesting to whitelist POST requests to /wp-json/*. Hostinger's Manage panel currently doesn't offer custom CDN rule configuration β€” you must go through support.

Option 3: Add Retry Logic in Code (Defensive Measure)​

Even with correct CDN configuration, retry logic handles occasional CDN throttling:

import time
import random

def upload_image(url, image_bytes, filename, auth, max_retries=3):
for attempt in range(max_retries):
resp = httpx.post(
url,
content=image_bytes,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "image/jpeg",
},
auth=auth,
timeout=30,
)
if resp.status_code != 405:
return resp
delay = 3 * (attempt + 1) + random.uniform(0, 2)
time.sleep(delay)
resp.raise_for_status()

Troubleshooting Journey​

This issue led down several dead ends. Here's the fullζŽ’ζŸ₯ path for reference:

HypothesisActionResult
WP plugin blockingDisabled Speed Optimizer / Auto Upload ImagesStill 405, ruled out
Rate limitingAdded 2-5s delay between uploads + retryStill 405, ruled out
REST API disabledGET /wp-json/wp/v2/settingsReturned normally, ruled out
Auth credentialsWC Test ConnectionSucceeded, ruled out
CDN blockingcurl -v to inspect response headersserver: hcdn confirmed CDN blocking

The turning point was using curl -v and spotting server: hcdn β€” only then did we realize the requests never reached WordPress.

Important Notes

  • After disabling CDN, DNS cache may take a few minutes to refresh β€” don't retry immediately
  • If your site is on Hostinger and uses REST API for batch operations, test CDN behavior before going live
  • WooCommerce WC API (/wc/v3/products) uses different authentication (Consumer Key) and is typically unaffected; this mainly impacts WP REST API (/wp-json/wp/v2/*) write operations

FAQ​

Why does WordPress REST API image upload return 405 Not Allowed?​

Check the server field in response headers. If it shows hcdn (Hostinger CDN) or another CDN identifier, the request is being blocked at the CDN layer before reaching WordPress. Disable the CDN or contact your hosting provider to whitelist the endpoint.

How to tell if 405 comes from CDN or WordPress?​

Use curl -v and inspect response headers: a server value of hcdn, cloudflare, or other CDN identifiers indicates CDN-level blocking; a server value of nginx/apache with X-WP-* or X-RateLimit-* headers means the request reached WordPress.


Encountered this issue while building a WooCommerce product import tool for a client. If you're also developing with Hostinger + WordPress and running into REST API issues, reach out.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me