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 在必要時中止請求。 |
最佳實踐
- 統一錯誤處理函式:在專案根目錄建立
api.js,把fetchWithRetry、exponentialBackoff等封裝起來,所有模組共用。 - 紀錄與監控:在每次重試或失敗時寫入 Log,配合前端監控平台(如 Sentry)追蹤失敗率。
- 使用型別(TypeScript):明確宣告回傳資料結構,減少因錯誤回傳而產生的 runtime 錯誤。
- 測試:利用 Jest 或 Vitest 模擬網路斷線、5xx 回傳,驗證重試邏輯是否如預期。
實際應用場景
表單提交
使用者填寫表單後送出資料,若因手機訊號不穩而失敗,自動重試 2 次,成功後再顯示「送出成功」訊息,避免使用者重複提交。即時資料同步
單頁應用(SPA)常需要定時向後端拉取最新資料。當伺服器回傳 503 時,指數退避 可讓前端在短暫的服務中斷期間仍保持資料同步。上傳大量檔案
大檔案上傳失敗時,配合 分段上傳(Chunked upload)與 每段重試,確保即使某段暫時失敗,也不必重新上傳整個檔案。第三方 API 整合
多數外部 API 會在流量高峰回傳 429(Too Many Requests)。此時 遵守Retry-After標頭,或使用退避策略,可避免被封鎖。
總結
fetch本身只會在網路層面拋出錯誤,HTTP 錯誤必須自行檢查response.ok。- 錯誤分類(暫時性 vs 永久性)是決定是否重試的關鍵。
- 重試策略應包括 最大次數、延遲、指數退避、以及 jitter,以降低對伺服器的衝擊並提升使用者體驗。
- 把 錯誤處理與重試 抽象為可重用的函式或工具類別,能讓整個專案保持一致性、易於維護。
透過本文提供的概念與範例,你已具備在實務專案中實作可靠的 fetch 錯誤處理與重試機制的能力。將這些技巧應用於日常開發,能讓你的前端應用在面對不穩定的網路環境時,依舊保持流暢與穩定。祝開發順利!