
查外送進度和催外送員是兩回事。一直打電話問「到了沒」,對方還在路上當然說沒到——但沒人會把這個當成「訂單取消」。非同步 API 輪詢踩的坑,邏輯上完全一樣,偏偏寫 code 的時候很少停下來想這一層。
技術環境
n8n 工作流程呼叫 fal.ai queue API,輪詢節點以 JavaScript 編寫。fal.ai 採非同步佇列架構,任務提交後分兩個端點回傳資料:status_url 查詢進度,response_url 取得最終結果。輪詢節點以固定間隔打 HTTP 請求,根據狀態碼決定繼續等待或進入錯誤分支。問題核心在於端點選錯——response_url 在任務未完成時本就不應被輪詢,與 fal.ai 服務穩定性無關。
現象
非同步任務系統連續多次回報「發布失敗」。查日誌,第三方服務那邊其實已經生成完成,結果照樣拿得到。是這端的輪詢邏輯提前棄械,把任務當失敗砍掉,觸發重試,再失敗,再重試——服務一直在跑,客戶端一直在自爆。
錯誤傳染鏈(時序)
n8n Polling Node fal.ai API
| |
|── GET response_url ─>|
| | (task still in queue)
|<── HTTP 400 ─────────| detail: "Request is still in progress"
| |
| catch(4xx) ────────>| ← 誤判為終止錯誤 ✗
| mark as FAILED |
| cancel & retry |
| |
|── GET response_url ─>| (重試,端點仍錯)
|<── HTTP 400 ─────────|
| cancel & retry | ← 再次自爆,週期重複
| … |
fal.ai 狀態: COMPLETED ✓ / n8n 認知: FAILED ✗
每次 400 被誤判為終止錯誤,輪詢節點自爆後重試;fal.ai 那端任務已正常完成,這端累積的只是無效重試,結果永遠拿不到。
分界點
第三方服務提供兩個端點:response_url 用來取結果,status_url 用來查狀態。當任務還在排隊,打 response_url 會回 HTTP 400,body 裡夾帶 "detail": "Request is still in progress"。輪詢邏輯看到 4xx 就全部當真錯誤處理,當場判死。
問題不在服務,在於把「還沒好」和「真的壞了」混進了同一個分支。
容易誤判的原因
兩個端點名稱相近,文件不一定講清楚哪個才是「查進度」用的。更根本的是,400 Bad Request 在 HTTP 語意上通常代表請求本身有問題——送的東西格式錯、參數不對。用 400 來表達「任務還在跑」,這個設計本身就反直覺;慣例上應該是 202 或自訂 status code 配合 pending 欄位。
fal.ai queue API 的回應格式就藏著這層細節:處理中時,detail 欄位是字串;FastAPI 驗證錯誤時,detail 欄位是陣列。如果用 if (resp.detail) 判斷失敗,任何處理中的回應都會被誤判——因為字串也是 truthy。正確的分法是 typeof resp.detail === 'object' && resp.detail.length,只有陣列才算真正的驗證錯誤。
還有一個更陰的地方:即使 status=COMPLETED,也不保證結果可用。response 本體可能還是 422。狀態完成和結果有效,是兩件事。
確認方式
A/B 實測:改打 status_url 輪詢,只有拿到 status=COMPLETED 才走 Fetch 結果的節點;FAILED 才進錯誤分支。全程走通,任務在數分鐘內完成並成功發布。問題從頭到尾都是輪詢端點選錯,服務本身從來沒壞過。
Code 對照:修法前後
修法前:直接輪詢 response_url,任何 4xx 一律當終止錯誤。
// 輪詢錯誤端點
const resp = await fetch(response_url);
if (!resp.ok) { // ← 4xx 全部判死 ← 問題在這裡
throw new Error(`Failed: ${resp.status}`);
}
const result = await resp.json();
// detail 判斷錯誤
if (resp.detail) { // ← 字串 truthy,in-progress 被誤判
throw new Error('Validation error');
}
修法後:改輪詢 status_url,明確拆分 pending / completed / failed 三條分支。
// 輪詢正確端點
const statusResp = await fetch(status_url);
const statusData = await statusResp.json();
if (statusData.status === 'COMPLETED') {
const resultResp = await fetch(response_url); // 確認完成才取結果
const result = await resultResp.json();
// 真正的驗證錯誤:detail 必須是陣列(FastAPI 格式)
if (Array.isArray(result.detail) && result.detail.length > 0) {
throw new Error('Validation error');
}
return result;
} else if (statusData.status === 'FAILED') {
throw new Error('Task failed on server'); // 確認失敗才報錯
} else {
// IN_QUEUE / IN_PROGRESS → 繼續等
await delay(pollIntervalMs);
return poll();
}
該被隔離的側效應類型
- 重試計數膨脹:每次誤判都觸發 retry,計數累積到上限;任務從未真正失敗,這些重試全是白跑。
- 錯誤日誌污染:false alarm 的 error log 和真正失敗的訊息混在一起,下次排查難以分辨信號與雜訊。
- 下游 webhook 誤觸:若輪詢失敗後接「失敗通知 webhook」,外部系統收到錯誤報告,誤以為服務異常。
- 重複任務提交:誤以為失敗後重新提交 fal.ai,佇列出現多份相同請求,API 配額白費,結果可能重複生成。
- 快取狀態失效:若有快取記錄「任務 ID → 失敗」,即使 fal.ai 已有結果,快取也不會更新,後續查詢永遠拿到錯誤狀態。
- Analytics 計數偏低:失敗事件寫入統計,成功率與任務完成率的數字失真,影響後續指標判斷。
- Async queue 重入污染:重試任務進入佇列,和原始任務競爭處理資源,queue 堆積速度超過消化速度。
判斷標準:如果某個動作「在輪詢誤判時不應被觸發,卻照樣跑了」,就需要等確認狀態後才執行,不能直接接在 4xx catch 後面。
留給未來的話
非同步服務的輪詢邏輯,有兩個階段必須拆開:狀態查詢要容忍 pending,可以等很久;結果提取只在確認完成後才呼叫。如果 API 設計把兩者混在一起,就得在客戶端自己拆解語意——「還沒好」和「真的壞了」要明確走不同分支,不能共用一個 4xx catch-all。
下次輪詢邏輯回報失敗,先確認對方日誌:如果服務那端顯示已完成,問題八成在這端的判斷邏輯,不在服務。
— 邱柏宇
A 400 Isn’t a Failure — It Just Isn’t Done Yet
Checking a delivery status and calling the courier to complain are different actions. If the courier is still on the way and says “not yet,” that’s not a cancellation — it’s just a pending state. Async API polling hits exactly this wall, and it’s surprisingly easy to wire the wrong behavior in.
Technical Environment
An n8n workflow calls the fal.ai queue API using a JavaScript polling node. fal.ai uses an async queue architecture: submitted jobs are served through two separate endpoints — status_url for progress checks and response_url to retrieve the final result. The polling node fires HTTP requests at a fixed interval and routes on response status code. The root cause is polling the wrong endpoint — response_url is not designed to be polled while a job is in progress, and fal.ai’s service stability was never in question.
What Happened
An async task pipeline repeatedly reported publish failures. Checking the third-party service logs told a different story: the job had completed successfully on their end. The client-side polling logic had misread the in-progress signal as a hard error, killed the task, triggered a retry, and repeated the cycle. The service kept running. The client kept self-destructing.
Error Propagation Sequence
n8n Polling Node fal.ai API
| |
|── GET response_url ─>|
| | (task still in queue)
|<── HTTP 400 ─────────| detail: "Request is still in progress"
| |
| catch(4xx) ────────>| ← misclassified as terminal error ✗
| mark as FAILED |
| cancel & retry |
| |
|── GET response_url ─>| (retry, still wrong endpoint)
|<── HTTP 400 ─────────|
| cancel & retry | ← self-destructs again; cycle repeats
| … |
fal.ai status: COMPLETED ✓ / n8n belief: FAILED ✗
Each misclassified 400 fires a retry; fal.ai completes the job successfully on its end while the client accumulates wasted retries and never retrieves the result.
Where the Split Is
The third-party service exposes two distinct endpoints: response_url to fetch the result, and status_url to check job status. When a task is still queued, hitting response_url returns HTTP 400 with a body containing "detail": "Request is still in progress". The polling logic treated every 4xx as a terminal failure and bailed immediately.
The service never broke. The classification logic was simply wrong.
Why It’s Easy to Get Wrong
The two endpoint names look similar — easy to mix up under time pressure. The deeper issue is that 400 Bad Request conventionally means the request itself is malformed: wrong parameters, bad format. Using 400 to signal “job not ready yet” is counterintuitive; the expected pattern would be a 202 Accepted with a polling loop, or a custom status code with a pending field.
The fal.ai queue API response format captures a subtler version of this: when a job is in progress, detail is a string. When FastAPI throws a validation error, detail is an array. Code written as if (resp.detail) to detect failure will catch every in-progress response too — a non-empty string is truthy. The correct check is typeof resp.detail === 'object' && resp.detail.length, which only flags real validation errors.
There’s another layer: even when status=COMPLETED, the result body might still be a 422. Job completion and result validity are separate conditions.
How It Was Confirmed
A/B test: switch to polling status_url instead. Only proceed to fetch the result when status=COMPLETED is returned; only branch to the error path on FAILED. The pipeline ran to completion and published successfully. The root cause was polling the wrong endpoint from the start — nothing else.
Code Diff: Before and After
Before: polling response_url directly; any 4xx classified as a terminal failure.
// Polling wrong endpoint
const resp = await fetch(response_url);
if (!resp.ok) { // ← all 4xx treated as terminal ← problem here
throw new Error(`Failed: ${resp.status}`);
}
const result = await resp.json();
// Incorrect detail check
if (resp.detail) { // ← string is truthy; in-progress misclassified
throw new Error('Validation error');
}
After: poll status_url first; explicitly branch on pending / completed / failed.
// Poll correct endpoint
const statusResp = await fetch(status_url);
const statusData = await statusResp.json();
if (statusData.status === 'COMPLETED') {
const resultResp = await fetch(response_url); // only fetch once confirmed done
const result = await resultResp.json();
// Real validation error: detail must be an array (FastAPI format)
if (Array.isArray(result.detail) && result.detail.length > 0) {
throw new Error('Validation error');
}
return result;
} else if (statusData.status === 'FAILED') {
throw new Error('Task failed on server'); // only fail on confirmed failure
} else {
// IN_QUEUE / IN_PROGRESS → keep waiting
await delay(pollIntervalMs);
return poll();
}
Side Effects That Should Be Isolated
- Retry counter inflation: each misclassified poll fires a retry, burning through the retry limit — even though the job never actually failed.
- Error log pollution: false-alarm errors mix with real failures, making future debugging significantly harder to distinguish signal from noise.
- Downstream webhook misfires: if a failure event triggers a notification webhook, external systems receive an incorrect error report and may act on it.
- Duplicate job submissions: retrying a job that already completed re-submits to fal.ai, wastes API quota, and may generate duplicate results.
- Cache state corruption: if a job ID is cached as “failed,” the entry never updates even after fal.ai delivers a valid result — subsequent lookups stay wrong.
- Analytics miscounting: failed-task events written to metrics artificially lower success rate and task completion rate, skewing future decisions.
- Async queue reentrance: retry jobs compete with the original task for processing resources, causing the queue to grow faster than it drains.
The test: if an action should not fire when a poll misclassifies a pending state, it must not sit in the 4xx catch block — gate it behind confirmed completion or confirmed failure.
For Next Time
Any async polling loop needs two clearly separated phases: status checks that tolerate pending (and wait as long as needed), and result fetches that only fire after confirmed completion. If the API design blurs these together, the client has to enforce the split — “not ready yet” and “actually failed” must route to different branches, never into the same 4xx catch-all.
When a polling loop reports failure, check the service logs first. If the job shows completed on their side, the bug is almost certainly in the classification logic on this end.
— 邱柏宇
延伸閱讀
- fetch is not defined,沙箱不是 Node.js
- 週報第一行偶發亂碼:UTF-8 字元被 TCP chunk 切半
- robots.txt 一直都在,只是裡面裝的是 HTML
- 那通超時的請求,對方早就接到了
- 過濾器沒有出錯,它只是把連結全部吃掉了
https://justfly.idv.tw/s/izrEvqx