本文 AI 產出,尚未審核

JavaScript – Fetch 與網路請求(Networking)

錯誤處理與重試策略

簡介

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

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


核心概念

1. fetch 的錯誤類型

fetch 只會在網路層面(例如 DNS 失敗、無法連線)拋出 Promise 的 reject。HTTP 狀態碼 4xx/5xx 不會自動視為錯誤,必須自行檢查 response.okresponse.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 中檢查 okstatus
無限制重試 造成無窮迴圈或雪崩效應。 設定 最大次數,並使用 退避
重試所有錯誤 例如 400、401、404 這類業務錯誤不應重試。 只對 暫時性錯誤(5xx、429、網路斷線)重試。
延遲時間寫死 大量客戶端同時重試會瞬間衝擊伺服器。 加入 隨機 jitter,或使用 指數退避
未處理 AbortSignal 使用者離開頁面或切換路由時請求仍在執行。 搭配 AbortController 在必要時中止請求。

最佳實踐

  1. 統一錯誤處理函式:在專案根目錄建立 api.js,把 fetchWithRetryexponentialBackoff 等封裝起來,所有模組共用。
  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 錯誤處理與重試機制的能力。將這些技巧應用於日常開發,能讓你的前端應用在面對不穩定的網路環境時,依舊保持流暢與穩定。祝開發順利!