Skip to main content

2 posts tagged with "REST API"

View all tags

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