CodeGym /課程 /ChatGPT Apps /系統韌性:timeouts、circuit breakers、bulkheads、Webhook 風暴防護

系統韌性:timeouts、circuit breakers、bulkheads、Webhook 風暴防護

ChatGPT Apps
等級 16 , 課堂 2
開放

1. 為什麼在 ChatGPT App 中要重視「韌性」

在一般的 Web 應用中,使用者至少看得到 URL、瀏覽器的轉圈圈,也能重新整理頁面。在 ChatGPT 裡,使用者只看到一個畫面:聊天與你的 App。若有東西變慢,他無法分辨是誰的錯——OpenAI、你的 Gateway、金流,或隔壁的分析微服務。對他而言,這一切都是「ChatGPT + 你的 App」。

tool-call 掛住 3060 秒,模型一直等、一直等……充其量只會為延遲道歉;更糟的是,它可能用幻覺式答案取代你後端的資料。因此,韌性不只是關於 SRE 與 uptime,還關係到答案品質、模型語氣與 Store 指標。

在 ChatGPT App 生態裡,有數個彼此獨立的路徑:

  • ChatGPT ↔ MCP Gateway。
  • Gateway ↔ 你的 backend/REST 服務(Gift REST API、Commerce REST API、Analytics Service 等等)。
  • 你的服務 ↔ 外部 API(LLM、支付、目錄)。
  • 傳入的 Webhook(ACP、Stripe、各種整合)↔ 你的處理器。

問題在於,任一處的故障都可能觸發連鎖反應:Gateway 老實等待卡住的服務,worker 塞滿、連線用盡,客戶端開始重試,幾分鐘後你就遇到經典的「地獄模式」:同時著火又進水。今天要談的四個模式正是為了防這些狀況:

  • Timeouts——我們從不無限等待。
  • Circuit breaker——我們不對著關上的門猛撞。
  • Bulkheads——把「艙」隔開,避免整艘船一起沉。
  • Webhook 風暴防護——承認 Webhook 會重複、尖峰、重試,並預先設計好因應。

2. Timeouts:我們不會無限等待

什麼是 timeout,為何沒有它會很糟

Timeout 是你的程式願意等待相依元(資料庫、MCP 伺服器、外部 HTTP API、模型)回應的最長時間。若在設定時間內未回應——就視為失敗,釋放資源並回傳可理解的錯誤或 fallback。

沒有 timeout 的請求可能會:

  • 永遠掛在等待中,
  • 佔住連線與執行緒池,
  • 阻塞後續請求,
  • 引發連鎖故障。

基本原則:「與其 35 秒內的可預期失敗,不如 5 分鐘的莫名靜默」。

務必記得,timeout 存在於多個層級:

  • 代理/負載平衡層(Cloudflare、Nginx),
  • MCP Gateway 層(對微服務的 HTTP 客戶端),
  • 服務內部(對資料庫、外部 API、LLM 的呼叫)。

對 ChatGPT 而言,整體 tool-call 的時間,常見作業以 510 秒為宜,特別重的最多 2030 秒。更久幾乎保證是糟糕的 UX。

TypeScript 中的簡易 fetchWithTimeout

從實作開始。在 GiftGenius MCP Gateway 裡,我們有個輔助的 HTTP 客戶端,會呼叫 gift 選品、commerce 服務、分析服務。把標準的 fetch 包成帶逾時的函式:

// src/gateway/httpClient.ts
export async function fetchWithTimeout(
  url: string,
  opts: RequestInit & { timeoutMs?: number } = {}
) {
  const { timeoutMs = 5000, ...rest } = opts;
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fetch(url, { ...rest, signal: controller.signal });
  } finally {
    clearTimeout(timeoutId);
  }
}

現在在 Gateway 的程式裡,我們不再直接呼叫「裸」的 fetch,只透過這個 helper:

// src/gateway/giftClient.ts
import { fetchWithTimeout } from "./httpClient";

export async function callGiftService(path: string) {
  const res = await fetchWithTimeout(
    process.env.GIFT_SERVICE_URL + path,
    { timeoutMs: 4000 }
  );

  if (!res.ok) {
    throw new Error(`gift_service_${res.status}`);
  }
  return res.json();
}

如此即便 gift 服務卡住,過了 4 秒我們就會中斷連線,將 MCP 錯誤回傳給 ChatGPT,而不是把連線硬撐到極限。

在 GiftGenius 中該把逾時設在哪裡

在我們的 GiftGenius 範例中:

  • Gateway 層:對 Gift REST API、Commerce REST API、Analytics Service / REST API 的呼叫設置逾時。
  • 各服務內部:對資料庫、ACP/金流、外部推薦 API 的呼叫設置逾時。
  • Gateway 入口:對來自 ChatGPT 的整體請求設置總逾時,避免 tool-call 變成「永遠的轉圈」。

重點在於最上層的等待時間要比內部略長。例如 Gateway 等待後端 5 秒,而後端等待資料庫 3 秒,那就有處理與序列化結果的緩衝。

如何向 ChatGPT 模型說明逾時

對 ChatGPT 而言,回傳具語意的錯誤比默默斷線更好。與其抽象的 500,不如回傳結構化的 MCP 錯誤,讓模型能向使用者說明:「禮物選品服務目前過載,請稍後再試」,等等。

這表示在 Gateway 中遇到逾時時要:

  1. 捕捉 AbortError 或自訂的 timeout_…
  2. 建立帶有意義代碼與簡短說明的 MCP 回應。
  3. 讓模型能決定如何對人解釋。

Timeout 解決的是「單次請求掛住」;但當某個相依開始大量故障時,僅有 timeout 仍擋不住一波波相同的失敗嘗試。這就需要下一層保護——circuit breaker。

3. Circuit breaker:對付垂死服務的「斷路器」

直覺:為什麼只有 timeout 還不夠

我們已能用 timeout 限制單次呼叫的等待時間。Timeout 保護的是單一呼叫。但若相依服務「徹底」掛了(例如 commerce 服務每個請求都因 OOM(Out Of Memory)而倒),我們仍會一直呼叫它、每次等 35 秒、接著抓錯、消耗網路與 CPU,然後再等一次。

Circuit breaker(斷路器)加入了記憶:它追蹤錯誤與逾時,當數量太多時,乾脆不再對該服務送出請求,改為立即回傳快速失敗或 fallback。過一段時間再以 half-open 模式小心嘗試。

斷路器的典型狀態:

  • Closed——一切正常,請求照常發送。
  • Open——服務視為「掛了」,不再送請求,直接回錯誤。
  • Half-open——只嘗試少量請求;若成功則回到 closed,若再失敗就回到 open

簡易的 circuit breaker 圖示

一張小圖:

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: 錯誤過多
    Open --> HalfOpen: 冷卻期已過
    HalfOpen --> Closed: 連續多次成功
    HalfOpen --> Open: 再次出錯
    Open --> Open: 快速拒絕

TypeScript 中的迷你 circuit breaker 實作

在生產環境通常會用現成套件(Node.js 上有例如 opossum 或輕量自製方案),但理解其機制只需一個精簡類別。

以下是包住 commerce 模組呼叫的極簡 breaker 範例:

// src/gateway/circuitBreaker.ts
type State = "closed" | "open" | "half-open";

export class CircuitBreaker {
    private state: State = "closed";
    private failureCount = 0;
    private nextAttemptAt = 0;

    constructor(
        private readonly failureThreshold = 5,
        private readonly cooldownMs = 30_000
    ) {}

    async call<T>(fn: () => Promise<T>): Promise<T> {
        const now = Date.now();

        if (this.state === "open") {
            if (now < this.nextAttemptAt) {
                throw new Error("circuit_open");
            }
            this.state = "half-open";
        }

        try {
            const result = await fn();
            this.onSuccess();
            return result;
        } catch (err) {
            this.onFailure();
            throw err;
        }
    }

    private onSuccess() {
        this.failureCount = 0;
        this.state = "closed";
    }

    private onFailure() {
        this.failureCount++;
        if (this.failureCount >= this.failureThreshold) {
            this.state = "open";
            this.nextAttemptAt = Date.now() + this.cooldownMs;
        }
    }
}

在 commerce 客戶端中的用法:

// src/gateway/commerceClient.ts
const commerceBreaker = new CircuitBreaker(3, 20_000);

export async function callCommerce(path: string) {
    return commerceBreaker.call(async () => {
        const res = await fetchWithTimeout(
            process.env.COMMERCE_URL + path,
            { timeoutMs: 3000 }
        );
        if (!res.ok) throw new Error(`commerce_${res.status}`);
        return res.json();
    });
}

當 commerce 開始大量回錯或來不及在逾時前回應,累積數次失敗後,breaker 會進到 open。在 cooldownMs 期間,我們完全不再嘗試呼叫該服務,而是直接回傳 circuit_open 的快速錯誤。

當 breaker 切斷服務時,ChatGPT 應該看到什麼

對 ChatGPT 來說,更好的是你:

  • 快速回傳 MCP 錯誤,例如「commerce_unavailable」或「gift_service_overloaded」。
  • 附上清楚說明:「支付服務暫時不可用,我們稍後再試」。
  • 不要用無限重試把錯誤藏起來。

這正是「快速且誠實的失敗」優於長時間掛住的情境。特別是在結帳流程:使用者比起盯著轉圈 40 秒後看到「出了點問題」,更能接受明確訊息。

Timeout 與 breaker 能保護我們免於「壞掉」或停擺的相依,但無法解決「某一類負載吃掉所有資源、扼殺系統其它部分」的問題。這就需要另一層——bulkheads。

4. Bulkheads:把「隔艙」做好,避免一處讓整艘船沉

船艙類比

Bulkhead 模式源自船艙的艙壁:若某一艙進水,水不會漫到整艘船。架構上的意涵是:分割資源到不同工作面向,避免某個過載的服務吃光所有資源——CPU、連線、各種池——並拖垮關鍵路徑。

在微服務中,通常透過獨立的:

  • HTTP 連線池,
  • 執行緒/worker 池,
  • 佇列/主題,
  • 甚至關鍵操作使用獨立資料庫叢集。

想法是:如果禮物推薦服務變慢、卡卡的,它最多耗盡自己的資源,不會把結帳與授權也拖下水。

在 Node.js 與 MCP Gateway 的 bulkhead 作法

在 Node.js 中沒有傳統意義的執行緒(有 event loop 與 workers),但我們可以限制每個方向的同時處理數量

例如,Gateway 有三個外部相依:

  • Gift 服務(禮物選品,LLM 負載重)。
  • Commerce 服務(結帳、ACP)。
  • Analytics 服務(事件記錄)。

我們可以為它們各自設定並行限制。

以下是一個限制並行度的小型「信號量」:

// src/gateway/bulkhead.ts
export class Bulkhead {
    private active = 0;
    private queue: (() => void)[] = [];

    constructor(private readonly maxConcurrent: number) {}

    async run<T>(fn: () => Promise<T>): Promise<T> {
        if (this.active >= this.maxConcurrent) {
            await new Promise<void>((resolve) => this.queue.push(resolve));
        }
        this.active++;

        try {
            return await fn();
        } finally {
            this.active--;
            const next = this.queue.shift();
            if (next) next();
        }
    }
}

用在各服務上:

// src/gateway/clients.ts
import { Bulkhead } from "./bulkhead";

const giftBulkhead = new Bulkhead(10);      // 最多 10 個並行
const commerceBulkhead = new Bulkhead(3);   // 結帳嚴格限制
const analyticsBulkhead = new Bulkhead(50); // 可以很多

export async function callGiftWithBulkhead(fn: () => Promise<any>) {
    return giftBulkhead.run(fn);
}

export async function callCommerceWithBulkhead(fn: () => Promise<any>) {
    return commerceBulkhead.run(fn);
}

因此,即使 GPT 決定同時請你做 30 個複雜的禮物選品,它們最多同時跑 10 個;而結帳仍可照常進行,因為有各自獨立的限制。

GiftGenius:我們要怎麼分「艙」

在 GiftGenius 中,合理的隔艙包括:

  • 禮物選品(LLM 重、優先級較低,可放慢)。
  • Checkout/ACP(超關鍵,需最大程度保護)。
  • 分析/日誌(重要,但可接受些許延遲)。

進一步的設計甚至會把它們以不同叢集部署、給不同資源。但在本講座重點是概念:不要讓次要功能「吃掉」全部資源。

上述三個模式——timeouts、circuit breaker 與 bulkheads——都關於你如何向「外部」呼叫、存取相依。還有另一類韌性威脅:傳入事件流,即使你的對外呼叫配置完美,也可能被淹沒。最典型的就是 Webhook 風暴。

5. Webhook 風暴:當外界送進來的事件比你能處理的還多

Webhook 在真實環境的行為

韌性的第四個來源是傳入事件:來自 ACP、Stripe 等系統的 Webhook。即使你已設好 timeouts、circuit breaker 與 bulkhead,它們仍可能掀起真正的「風暴」。

Webhook 不是「按需」的 HTTP 請求,而是外部系統(Stripe、ACP、外部商店等)的「push」事件。它們有幾個不太友善的特性:

  • 採用至少一次投遞(at-least-once)——代表重複是無可避免的。
  • 不保證投遞順序。
  • 遇錯會重試:先隔一秒,接著 10 秒,再來一分鐘……直到你回 2xx
  • 尖峰時段(例如特賣)會批次湧入,形成「風暴」。

若你的處理器不具冪等性且處理過久,就會變成瓶頸;整個佇列塞住,而重試只會加劇風暴。最終可能把資料庫、佇列、worker 池都搞掛,進而拖垮系統其它部分。

防禦風暴的基本原則

有幾個做法能大幅提高在風暴中的存活率:

首先,queue-first, process-later。理想上,傳入的 Webhook 不該在同步路徑做重工作。相反地,它應該儘快驗簽/驗格式,把工作丟進佇列,並回 200 OK。處理改由背景 worker 非同步進行。若需要給 ChatGPT「快速確認」,可維持獨立的通知路徑。

其次,處理器的冪等性。對同一操作的重複 Webhook 不應「再次建立訂單」或「重複扣款」。通常透過保存 idempotency key 或 eventId,並檢查事件是否已處理來達成。

第三,在接收端進行速率限制與 circuit breaker。即使送件端在狂風暴雨,你仍可以:

  • 按 IP/訂閱/endpoint 做 RPS 限制,
  • 暫時回 429503,放慢對方重試,
  • 在 downstream(如訂單資料庫)前使用 breaker,避免把流量倒進已故障的元件。

GiftGenius 中的 Next.js Webhook 處理器範例

假設我們有一個 ACP/金流系統,會把訂單狀態的 Webhook 送到 POST /api/commerce/webhook。我們希望:

  • 快速接收事件並放入佇列,
  • 不要在同步路徑處理,
  • 不會被重複事件搞壞。

以下是簡化版(未包含簽章驗證與真實佇列——這些會放在安全與佇列模組裡):

// app/api/commerce/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";

// 這裡本可接上 Redis/佇列,暫以陣列模擬
const inMemoryQueue: any[] = [];
const processedEvents = new Set<string>(); // 冪等性(示範用)

export async function POST(req: NextRequest) {
    const event = await req.json();

    const eventId = event.id as string;
    if (processedEvents.has(eventId)) {
        return NextResponse.json({ ok: true, duplicate: true });
    }

    // 在真實場景會驗證簽章與資料結構

    inMemoryQueue.push(event); // 放入佇列,留給背景處理
    // 背景 worker 稍後會處理並把 ID 標記為已處理
    return NextResponse.json({ ok: true });
}

這只是示意,但有兩個重點:

  1. 同步區段要盡可能輕量。
  2. 圍繞 event.id 做冪等性設計。

在實務上你會:

  • 使用外部佇列(SQS、RabbitMQ、Kafka),
  • 在資料庫保存已處理事件,
  • 驗證 Webhook 簽章與 payload 版本,
  • 必要時在處理器周圍套上獨立的 Bulkhead/Breaker。

在 GiftGenius 的情境中會是什麼樣子

對於透過 Webhook 與 ACP/Stripe 整合的 GiftGenius,防範風暴在尖峰季節(新年、黑色星期五)特別重要。當時事件很多:

  • intent 建立,
  • 支付確認,
  • 取消,
  • 退款。

若你的處理器開始「變慢」(例如呼叫外部 API 造成),你可能會遇到:

  • ACP 開始重試,
  • 事件批次湧入,
  • 訂單資料庫與 worker 池塞滿。

「queue first」+ 冪等性 + 入口速率限制,就是防範這類情境的保險。

6. 這些模式如何一起運作

現在把這些模式放進同一個流程,看看它們如何在真實路徑「選禮物並立即下單」中合奏。

以「ChatGPT → Gateway → Gift Service → Commerce → Webhook」這條鏈為例:

使用者在聊天中說:「幫我挑個禮物並直接下單」。

  1. 模型決定呼叫你的工具 suggest_and_checkout
  2. Gateway 透過 fetchWithTimeout 與 gift 服務的 bulkhead 呼叫 gift 服務。
  3. 若 gift 服務掛住——觸發逾時;環繞它的 breaker 在一定錯誤數後進入 open,接下來的請求會直接得到 MCP 錯誤「gift_service_unavailable」。
  4. 若 gift 回應,Gateway 便呼叫 commerce 服務(同樣帶逾時與獨立的 bulkhead)。
  5. 任何 commerce 的問題觸發另一個 circuit breaker,設定比 gift 更嚴格(因為結帳至關重要)。
  6. 成功下單後,ACP 會將 Webhook 傳到你的 /api/commerce/webhook;該端點把事件丟進佇列並快速回覆,背景 worker 負責處理;對同一個 eventId 的重複 Webhook 會被視為重複而忽略。

最後結果:

  • 卡住的選品服務不會拖垮結帳。
  • 卡住的 commerce 不會把所有 tool-calls 變成一分鐘的轉圈——ChatGPT 會快速收到有意義的錯誤。
  • Webhook 風暴不會破壞你的主要 HTTP 路徑。
  • 你能控制降級的位置:寧可暫時關掉個人化推薦,也不要讓支付倒下。

7. 你的 App 的實用檢查清單(敘述式)

總結來說,在典型的 ChatGPT App(含 MCP/Gateway)中,值得逐步檢視以下問題。

首先,檢查所有對外呼叫是否都有逾時。所有 fetch、資料庫與 LLM 請求都該使用像 fetchWithTimeout 這樣的包裝與合適數值。務必避免有請求可能無限掛住的地方。

接著,找出最脆弱的相依。通常是金流、ACP、龐大的外部 API,有時還包括你自己的訂單資料庫。為它們加上 circuit breaker,以免在明知已死的服務上引發重試雪崩。同時事先規劃好當 breaker 處於 open 時,ChatGPT 應如何表現。

之後,把資源當作「船艙」檢視。是否所有東西都走同一個連線池與同一個 worker 池?關鍵操作(登入、結帳)是否有獨立於推薦與分析的並行限制?若沒有——至少加入最基本的 bulkhead 實作,哪怕只是粗略限制同時任務數。

最後,稽核所有傳入 Webhook。檢查是否有 idempotency key 或 eventId、是否在 HTTP 處理器裡同步做重工作,以及當 downstream 暫時故障時你是否能承受一波重試。若否——把邏輯搬到佇列與背景 worker。

即便沒有超複雜的基礎設施,這樣的一連串步驟也能帶來非常可觀的韌性提升。

8. 使用 timeouts、circuit breakers、bulkheads 與 Webhook 風暴防護時的常見錯誤

錯誤 №1:在「底層某處」缺少逾時。
開發者常只在 Gateway 或前端設逾時,卻忘了後端裡還有資料庫、外部 API、LLM。結果外部請求看似有 5 秒逾時,但內部一次資料庫或金流呼叫卻可能卡上好幾分鐘,堵住連線池並引發連鎖失敗。

錯誤 №2:為了保險而放超大逾時。
有時會把逾時設為 60120 秒:「讓它跑完就好」。在 ChatGPT 情境幾乎總是壞主意。使用者離開、模型開始幻覺,而你的資源全被佔住。更好的做法是 510 秒內誠實地失敗,並附上可理解的說明。

錯誤 №3:沒有設計過 UX 的 circuit breaker。
有時為了「打勾」而加 breaker,但觸發時,丟給使用者或模型的是莫名的 500、「ECONNREFUSED」或「axios error」。結果 GPT 無法好好解釋,開始瞎掰。應該事先設計對人與模型都清楚的錯誤訊息。

錯誤 №4:沒有 bulkhead 思維地混用資源。
典型情境:某個推薦(或分析)服務開始變慢,吃掉所有資料庫連線池或 thread-pool,接著結帳與登入也跟著死。都因為資源未分艙。缺少任何程度的 bulkhead,會讓次要功能放倒整個生產環境。

錯誤 №5:把 Webhook 當成一般請求來處理。
新手常把 Webhook 處理器寫成一般控制器:長商業邏輯、對外部 API 的呼叫、沒有冪等性。在重試與重複的情況下,導致事件被處理兩次、訂單狀態怪異,且在風暴時因負載而掛掉。

錯誤 №6:在 commerce 情境忽視冪等性。
特別危險的是,支付 Webhook 可能再次建立訂單,或重複改變其狀態。若沒有檢查 idempotency key 與保存事件處理狀態,遲早會遇到重複扣款或奇怪的重複訂單。

錯誤 №7:試圖用 setTimeout 與「魔法延遲」修所有問題。
有人想用「等 100 ms 就好了」來繞過 race condition 與風暴。實務上只會讓系統更不穩,且對真正的故障毫無幫助。正確的路是明確的逾時、circuit breaker、佇列與冪等性,而不是用延遲在那邊作法。

錯誤 №8:沒有為關鍵路徑做優先級。
當結帳與登入與分析或推薦邏輯共享同樣限制時,任一過載都可能同樣地放倒關鍵與次要部分。在韌性設計上,checkout 與 auth 是「神聖不可侵犯」:給它們獨立資源、獨立限制、獨立告警與 SLO。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION