本文 AI 產出,尚未審核

JavaScript – Fetch 與網路請求(Networking)

錯誤處理與重試策略

簡介

在前端開發中,fetch 已成為向伺服器發送 HTTP 請求的標準 API。即使是最簡單的 GET 請求,也可能因為網路斷線、伺服器超時、或是 API 回傳錯誤碼而失敗。若不妥善處理這些失敗情況,使用者體驗會受到嚴重衝擊,甚至導致資料遺失或功能無法正常運作。

本篇文章將說明 如何捕捉 fetch 的錯誤、判斷錯誤類型,以及 實作常見的重試(retry)機制。透過實作範例,你可以在自己的專案裡快速加入可靠的錯誤處理,提升應用的韌性與使用者滿意度。


核心概念

1. fetch 的錯誤類型

fetch 只會在網路層面(例如 DNS 失敗、無法連線)拋出 Promise 的 reject。HTTP 狀態碼 4xx/5xx 不會自動視為錯誤,必須自行檢查 response.ok 或 response.status。

fetch('https://api.example.com/data')
  .then(response => {
    // 1xx、2xx、3xx 都會進入此處
    if (!response.ok) {               // ← 手動判斷非 2xx
      throw new Error(`HTTP ${response.status}`);
    }
    return response.json();           // 取得 JSON
  })
  .catch(err => console.error('網路或 HTTP 錯誤:', err));

2. 基本錯誤處理模式

最常見的做法是 先捕捉網路錯誤,再檢查 HTTP 狀態,最後根據需求顯示錯誤訊息或執行備援流程。

async function getData(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      // 依照不同狀態碼自訂錯誤訊息
      if (res.status === 404) throw new Error('找不到資源');
      if (res.status >= 500) throw new Error('伺服器錯誤,請稍後再試');
      throw new Error(`未知錯誤 (${res.status})`);
    }
    return await res.json();
  } catch (e) {
    // 這裡會捕捉到網路錯誤或上面拋出的自訂錯誤
    console.warn(e);
    // 可以回傳預設值或重新拋出讓上層處理
    return null;
  }
}

3. 為什麼需要「重試」?

  • 暫時性錯誤(Transient errors)如網路抖動、伺服器短暫過載、或是第三方服務的速率限制(429)常常在稍後就能恢復。
  • 使用者體驗:自動重試可避免使用者手動刷新或重新操作。
  • 資料完整性:在批次上傳或交易流程中,確保每筆請求最終成功可降低遺失風險。

4. 重試策略的要素

要素 說明
最大次數 (maxAttempts) 防止無限迴圈,常設為 3~5 次
延遲時間 (delay) 每次重試前等待的毫秒數,可固定或遞增
退避演算法 (exponential backoff) 延遲時間隨次數呈指數增長,減少對伺服器的衝擊
隨機抖動 (jitter) 在延遲時間上加入隨機因素,避免大量客戶端同時重試形成「雪崩效應」

5. 實作範例:簡易的固定延遲重試

/**
 * fetchWithRetry - 以固定延遲重試 fetch
 * @param {string} url 請求的網址
 * @param {object} [options] fetch 的選項
 * @param {number} [maxAttempts=3] 最大重試次數
 * @param {number} [delay=1000] 每次重試的等待時間(毫秒)
 */
async function fetchWithRetry(url, options = {}, maxAttempts = 3, delay = 1000) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        // 只對 5xx 或 429 進行重試,其他視為「業務錯誤」直接拋出
        if (response.status >= 500 || response.status === 429) {
          throw new Error(`HTTP ${response.status}`);
        }
        return response; // 2xx、3xx、4xx(非重試)直接回傳
      }
      return response; // 成功取得 2xx
    } catch (err) {
      console.warn(`第 ${attempt} 次請求失敗: ${err.message}`);
      if (attempt === maxAttempts) throw err; // 超過上限就拋出
      await new Promise(res => setTimeout(res, delay));
    }
  }
}

6. 實作範例:指數退避(Exponential Backoff)+ 隨機抖動

/**
 * exponentialBackoff - 依指數退避與 jitter 重試 fetch
 * @param {string} url
 * @param {object} [options]
 * @param {number} [maxAttempts=5]
 * @param {number} [baseDelay=500] 基礎延遲(毫秒)
 */
async function exponentialBackoff(url, options = {}, maxAttempts = 5, baseDelay = 500) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const resp = await fetch(url, options);
      if (!resp.ok) {
        if (resp.status >= 500 || resp.status === 429) {
          throw new Error(`HTTP ${resp.status}`);
        }
        return resp; // 非重試錯誤直接回傳
      }
      return resp; // 成功
    } catch (e) {
      if (attempt === maxAttempts) throw e;

      // 計算退避時間:baseDelay * 2^(attempt-1) + 隨機 jitter
      const jitter = Math.random() * 100; // 0~100ms 隨機抖動
      const delay = baseDelay * Math.pow(2, attempt - 1) + jitter;
      console.warn(`Retry ${attempt}/${maxAttempts} after ${Math.round(delay)}ms`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

7. 把重試封裝成通用工具

/**
 * retry - 通用的 async 函式重試工具
 * @param {Function} fn 必須回傳 Promise 的非同步函式
 * @param {object} opts
 *   - maxAttempts: 最大次數
 *   - delay: 基礎延遲
 *   - backoff: 是否使用指數退避
 */
async function retry(fn, { maxAttempts = 3, delay = 500, backoff = false } = {}) {
  for (let i = 1; i <= maxAttempts; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i === maxAttempts) throw e;
      const wait = backoff ? delay * Math.pow(2, i - 1) : delay;
      await new Promise(r => setTimeout(r, wait));
    }
  }
}

// 使用方式
retry(() => fetch('https://api.example.com/data'), { maxAttempts: 4, delay: 800, backoff: true })
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error('最終失敗:', err));

常見陷阱與最佳實踐

陷阱 說明 建議的做法
忘記檢查 response.ok 只捕捉 catch,卻忽略 4xx/5xx 回傳。 始終在 .then 中檢查 ok 或 status。
無限制重試 造成無窮迴圈或雪崩效應。 設定 最大次數,並使用 退避。
重試所有錯誤 例如 400、401、404 這類業務錯誤不應重試。 只對 暫時性錯誤(5xx、429、網路斷線)重試。
延遲時間寫死 大量客戶端同時重試會瞬間衝擊伺服器。 加入 隨機 jitter,或使用 指數退避。
未處理 AbortSignal 使用者離開頁面或切換路由時請求仍在執行。 搭配 AbortController 在必要時中止請求。

最佳實踐

  1. 統一錯誤處理函式:在專案根目錄建立 api.js,把 fetchWithRetry、exponentialBackoff 等封裝起來,所有模組共用。
  2. 紀錄與監控:在每次重試或失敗時寫入 Log,配合前端監控平台(如 Sentry)追蹤失敗率。
  3. 使用型別(TypeScript):明確宣告回傳資料結構,減少因錯誤回傳而產生的 runtime 錯誤。
  4. 測試:利用 Jest 或 Vitest 模擬網路斷線、5xx 回傳,驗證重試邏輯是否如預期。

實際應用場景

  1. 表單提交
    使用者填寫表單後送出資料,若因手機訊號不穩而失敗,自動重試 2 次,成功後再顯示「送出成功」訊息,避免使用者重複提交。

  2. 即時資料同步
    單頁應用(SPA)常需要定時向後端拉取最新資料。當伺服器回傳 503 時,指數退避 可讓前端在短暫的服務中斷期間仍保持資料同步。

  3. 上傳大量檔案
    大檔案上傳失敗時,配合 分段上傳(Chunked upload)與 每段重試,確保即使某段暫時失敗,也不必重新上傳整個檔案。

  4. 第三方 API 整合
    多數外部 API 會在流量高峰回傳 429(Too Many Requests)。此時 遵守 Retry-After 標頭,或使用退避策略,可避免被封鎖。


總結

  • fetch 本身只會在網路層面拋出錯誤,HTTP 錯誤必須自行檢查 response.ok。
  • 錯誤分類(暫時性 vs 永久性)是決定是否重試的關鍵。
  • 重試策略應包括 最大次數、延遲、指數退避、以及 jitter,以降低對伺服器的衝擊並提升使用者體驗。
  • 把 錯誤處理與重試 抽象為可重用的函式或工具類別,能讓整個專案保持一致性、易於維護。

透過本文提供的概念與範例,你已具備在實務專案中實作可靠的 fetch 錯誤處理與重試機制的能力。將這些技巧應用於日常開發,能讓你的前端應用在面對不穩定的網路環境時,依舊保持流暢與穩定。祝開發順利!