# 400 不等於壞掉，只是還沒好

- URL: https://justfly.idv.tw/400-%e4%b8%8d%e7%ad%89%e6%96%bc%e5%a3%9e%e6%8e%89%ef%bc%8c%e5%8f%aa%e6%98%af%e9%82%84%e6%b2%92%e5%a5%bd/
- 日期: 2026-07-20
- 分類: 我知故我在
- 標籤: app, javascript, server, web

![400 不等於壞掉，只是還沒好]

查外送進度和催外送員是兩回事。一直打電話問「到了沒」，對方還在路上當然說沒到——但沒人會把這個當成「訂單取消」。非同步 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)
       ||  ← 誤判為終止錯誤 ✗
       |  mark as FAILED      |
       |  cancel & retry      |
       |                     |
       |── GET response_url ─>|  (重試，端點仍錯)
       | 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](https://zh.wikipedia.org/wiki/%E5%BA%94%E7%94%A8%E7%A8%8B%E5%BA%8F%E6%8E%A5%E5%8F%A3) 設計把兩者混在一起，就得在客戶端自己拆解語意——「還沒好」和「真的壞了」要明確走不同分支，不能共用一個 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)
       ||  ← misclassified as terminal error ✗
       |  mark as FAILED      |
       |  cancel & retry      |
       |                     |
       |── GET response_url ─>|  (retry, still wrong endpoint)
       | 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](https://en.wikipedia.org/wiki/Asynchronous_I/O) 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.

— 邱柏宇
