Học bằng cách đọc theo thứ tự

Xử lý lỗi fetch và timeout — AbortController và thử lại

Lỗi HTTP mà fetch không báo, lỗi mạng khiến chính fetch thất bại, timeout bằng AbortController và setTimeout, và cách chỉ thử lại một lần.

Nhờ đọc response.ok, bạn biết được khi server báo file không tồn tại. Nhưng nếu mất kết nối, dòng await fetch sẽ báo lỗi trước khi bạn kịp đọc ok. Còn nếu phản hồi chậm, code cứ đứng chờ mà không sang dòng tiếp theo.

Bài này nói về cách nhận từng loại lỗi của fetch dưới dạng ngoại lệ, và cách hủy yêu cầu bằng AbortController.

Biến phản hồi thất bại thành ngoại lệ — throw new Error

Giả sử có ba màn hình cùng tải danh sách đơn hàng. Nếu loadJson trả về null khi thất bại như ở bài trước, màn hình nào cũng phải kiểm tra null, và chỉ cần một chỗ quên, dòng đọc giá trị phía sau sẽ báo lỗi.

Với lỗi HTTP (yêu cầu đã tới server nhưng nhận về status báo thất bại, như 403 hay 404), fetch không báo lỗi. Nếu bạn throw new Error(...) khi okfalse, phía gọi có thể xử lý thất bại tại một chỗ duy nhất bằng try / catch.

// Nếu ok là false, ném ra ngoại lệ kèm status
async function loadJson(path) {
  const response = await fetch(path);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${path}`);
  }
  return await response.json();
}

try {
  const orders = await loadJson("fixtures/vi/order.json");   // dòng này ném ra ngoại lệ
  console.log(`Đơn hàng: ${orders.length}`);                  // không bao giờ chạy
} catch (error) {
  // Console bài tập trả về 403, server khác thường là 404
  console.log(error.message);   // HTTP 403: fixtures/vi/order.json
}
throw đưa nguyên nhân tới catch
ok là false thìreturn nullorderschứa nullĐọcorders.lengthLỗi TypeError,mất mã 403ok là false thìthrowawait loadJsonném ra ngoại lệBỏ qua phần cònlại, sang catcherror.message làHTTP 403: …
Nếu return null, bạn sẽ gặp một lỗi khác ở dòng orders.length. Nếu throw, thông báo có kèm status sẽ tới được catch.

Với throw, kể cả màn hình quên viết catch, lỗi không được bắt vẫn ghi rõ HTTP 403. Thay vì viết if kiểm tra null trên từng màn hình, bạn chỉ cần quyết định sẽ bắt lỗi ở đâu.

Trên màn hình tình trạng giao hàng, bạn tải danh sách đơn hàng. Khung của hàm loadOrders đã có sẵn, và hai đường dẫn được ghi ở đầu console.

① Nếu phản hồi báo thất bại, hãy ném ra ngoại lệ với thông báo “Không tải được đơn hàng: ” nối với đường dẫn.

② Nếu thành công, hãy chuyển body thành mảng rồi trả về.

③ Hãy tải bằng đường dẫn đúng và in ra tổng tiền của các đơn hàng.

④ Hãy tải bằng đường dẫn gõ sai, và nếu thất bại thì in ra thông báo của ngoại lệ.

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Phân biệt lỗi mạng — TypeError và instanceof

Khi bạn đang ở ngoài và mất sóng, yêu cầu không tới được server và cũng không có phản hồi nào. Lỗi này rơi vào cùng catch với lỗi HTTP, nên nếu để nguyên như hiện tại, bạn sẽ hiện cùng một thông báo cho cả “file không tồn tại” lẫn “không kết nối được”.

Với lỗi mạng (network error — thất bại mà không có phản hồi nào tới, chẳng hạn vì mất kết nối hoặc domain không tồn tại), Promise mà fetch trả về bị rejected với một TypeError. Nếu kiểm tra instanceof TypeError trong catch, bạn có thể phân biệt nó với Error do chính bạn ném ra.

// loadJson giống ở phần trước (ném ra ngoại lệ nếu ok là false)
const urls = [
  "fixtures/vi/order.json",             // tới được server, nhưng file không tồn tại
  "https://shop.invalid/orders.json",   // domain .invalid không kết nối tới đâu cả
];

for (const url of urls) {
  try {
    await loadJson(url);
  } catch (error) {
    // Error do bạn tự ném, hay TypeError do fetch báo?
    const kind = error instanceof TypeError ? "Không kết nối được" : "Yêu cầu thất bại";
    console.log(`${kind} / ${error.name}: ${error.message}`);
  }
}
// Yêu cầu thất bại / Error: HTTP 403: fixtures/vi/order.json
// Không kết nối được / TypeError: Failed to fetch
Nơi mỗi loại lỗi phát sinh
try của phía gọi — dòng await loadJson(url)
  • Cả hai loại lỗi đều ném ra ngoại lệ ở dòng này và nhảy sang catch
  • Trong catch, kiểm tra error instanceof TypeError để phân biệt
Bên trong loadJson
  • if (!response.ok) — với order.json, có phản hồi tới và okfalse
  • throw new Error(...)Error do bạn tự ném
Bên trong await fetch(url)
  • https://shop.invalid/… — không có phản hồi nào tới
  • TypeError: Failed to fetchdo fetch báo
loadJson ném ra lỗi HTTP, còn fetch báo lỗi mạng. Cả hai đều tới cùng một catch, nên hãy phân biệt bằng kiểu lỗi.

TypeError từ fetch bên trong sẽ thoát khỏi loadJson trước khi tới được câu if. Vì mỗi màn hình muốn hiện thông báo riêng, loadJson chỉ ném ra ngoại lệ, còn việc kiểm tra kiểu lỗi để cho catch của phía gọi, nơi nhận cả hai loại lỗi.

Thông báo lỗi mạng khác nhau tùy trình duyệt

message của TypeErrorfetch báo khác nhau tùy trình duyệt: Failed to fetch trên Chrome và Load failed trên Safari. Nếu kiểm tra bằng cách so khớp nội dung thông báo, trên trình duyệt khác code sẽ rẽ sai nhánh. Hãy kiểm tra kiểu lỗi bằng instanceof TypeError.

Trên màn hình quản lý tồn kho, bạn hiện thông báo khác nhau cho từng loại lỗi. loadJsonurls (ba URL) đã được khai báo sẵn.

① Trong showStock, hãy tải danh sách sản phẩm và in ra một dòng như “Sản phẩm: 4”.

② Nếu yêu cầu không tới được server, hãy in ra “Không kết nối được tới server”.

③ Với các lỗi khác, hãy in ra “Không tải được danh sách sản phẩm”.

④ Hãy lần lượt truyền từng URL trong urls cho showStock.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Ngắt yêu cầu khi hết thời gian — AbortController

Server đang quá tải có thể mất hàng chục giây mới phản hồi. fetch không có tùy chọn nào để đặt thời gian chờ tối đa, nên dòng await fetch không chạy tiếp cho đến khi có phản hồi, và màn hình trông như bị treo ở trạng thái đang tải. Code trong phần này dùng slowFetch, một hàm thay cho fetch, mất 300ms mới phản hồi.

Nếu tạo một AbortController (object cho phép hủy một yêu cầu fetch đang chạy) rồi truyền vào theo dạng fetch(url, { signal: controller.signal }), khi bạn gọi abort(), yêu cầu sẽ thất bại với AbortError.

Để hủy một lời gọi đã hẹn giờ, hãy truyền timer ID (con số xác định lời gọi đã hẹn) mà setTimeout trả về cho clearTimeout.

// slowFetch là hàm thay cho fetch, được khai báo trong console của Bài tập 3; nó mất 300ms mới phản hồi và dùng giống hệt fetch
const controller = new AbortController();

// Hẹn gọi abort() sau 100ms và giữ lại timer ID
const timerId = setTimeout(() => controller.abort(), 100);

try {
  // Giống fetch, truyền signal ở đối số thứ hai
  const response = await slowFetch("fixtures/vi/orders.json", { signal: controller.signal });
  console.log(response.ok);     // phản hồi mất 300ms nên dòng này không chạy
} catch (error) {
  console.log(error.name);      // AbortError
} finally {
  clearTimeout(timerId);        // hủy lịch hẹn, phòng khi phản hồi tới trước
}
Hẹn giờ hay phản hồi tới trước
Hạn 100ms,phản hồi sau 300msỞ mốc 100ms,abort() chạyfetch thất bạivới AbortErrorcatch báohết thời gianHạn 3000ms,phản hồi sau 300msỞ mốc 300ms,phản hồi tớiclearTimeouttrong finallyabort() khôngbao giờ được gọi
Nếu phản hồi chậm hơn thời hạn, abort() chạy trước và yêu cầu thất bại. Nếu phản hồi tới kịp, hãy hủy lời gọi abort() đã hẹn.

Đặt clearTimeout trong finally giúp không còn lịch hẹn nào sót lại, dù yêu cầu thành công hay thất bại. Nếu abort() chạy trước khi body được đọc xong, json() cũng thất bại với AbortError, nên hãy đặt cả return await response.json() trong try, và chỉ sang finally sau khi đã đọc xong body.

Trên màn hình chi tiết gói dịch vụ, bạn đặt thời hạn cho việc tải danh sách thành viên. slowFetch, hàm thay cho một API chậm, đã được khai báo sẵn.

① Bên trong loadUsers, hãy hẹn giờ để hủy yêu cầu khi hết thời hạn.

② Hãy dùng slowFetch để lấy danh sách thành viên theo cách có thể hủy được, rồi trả về mảng của body.

③ Hãy hủy lịch hẹn dù yêu cầu thành công hay thất bại.

④ Với thời hạn 3000ms, hãy in ra số thành viên; với thời hạn 100ms, hãy in ra tên của lỗi.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Thử thêm một lần — thử lại và return await

Khi bạn dùng điện thoại trên tàu, kết nối có thể mất trong chốc lát và yêu cầu thất bại, nhưng thử lại ngay thì thường thành công. Thay vì lần nào cũng bắt người dùng tải lại trang, code có thể tự động thử lại để dữ liệu vẫn hiển thị trên màn hình.

Để thử lại (retry — gửi lại yêu cầu thất bại tới cùng URL), bạn gọi lại hàm bên trong catch. Nếu không giới hạn số lần, code sẽ gửi yêu cầu liên tục chừng nào còn mất kết nối, nên ở đây, nếu lần thứ hai cũng thất bại, lỗi sẽ được chuyển thẳng cho phía gọi.

// loadJson giống ở phần đầu. Chỉ thử lại một lần, và chỉ khi lỗi mạng hoặc hết thời gian
async function loadWithRetry(path) {
  try {
    return await loadJson(path);
  } catch (error) {
    const retryable = error instanceof TypeError || error.name === "AbortError";
    if (!retryable) {
      throw error;                                   // gửi lại cũng chỉ nhận cùng mã 403 hay 404
    }
    console.log(`Thử lại do ${error.name}`);         // Thử lại do TypeError
    return await loadJson(path);                     // nếu lần hai thất bại, lỗi chuyển thẳng cho phía gọi
  }
}

try {
  await loadWithRetry("https://shop.invalid/orders.json");
} catch (error) {
  console.log(`Thất bại cả hai lần: ${error.name}`);   // Thất bại cả hai lần: TypeError
}
Chỉ thử lại một lần
Gọi loadWithRetry(path)Lần 1thành côngLần 1TypeErrorLần 1TypeErrorKhông gọilần 2Lần 2thành côngLần 2 vẫnTypeErrorTrả về mảngcủa lần 1In thông báo thửlại, trả về mảngKhông có lần 3,ném ra ngoại lệ
Nếu lần đầu thành công, sẽ không có lần gọi thứ hai. Nếu lần hai cũng thất bại, sẽ không có lần ba; ngoại lệ được chuyển cho phía gọi.

Lỗi HTTP, như với fixtures/vi/order.json, được ném lại ngay trong catch đầu tiên, nên nó được chuyển cho phía gọi mà không có lần gọi thứ hai. Bảng dưới đây cho thấy cách nhận biết từng loại lỗi trong catch và có nên thử lại hay không.

Loại lỗiKiểm tra trong catchThử lại?
Lỗi mạngerror instanceof TypeErrorMột lần (có thể có mạng lại)
Hết giờ (abort())error.name === "AbortError"Một lần (server có thể bớt tải)
Lỗi HTTP (403, 404)Các trường hợp còn lạiKhông (vẫn nhận cùng phản hồi)

return không có await sẽ bỏ qua catch

Nếu bạn viết try { return loadJson(path); } mà không có await, hàm sẽ rời khỏi try ngay khi trả về Promise. Dù sau đó Promise bị rejected, catch cũng không chạy, và lỗi được chuyển thẳng cho phía gọi. Hãy viết return await loadJson(path);.

Bạn tải danh sách thành viên từ một API chậm. slowFetchloadWithin đã được khai báo sẵn.

① Trong loadWithRetry, hãy tải với thời hạn limitMs và trả về kết quả.

② Chỉ khi hết thời gian, hãy in ra “Hết thời gian, đang thử lại” rồi tải lại với thời hạn 3000ms.

③ Hãy tải thành viên bằng loadWithRetry và in ra một dòng như “Thành viên: 4”.

④ Hãy tải thêm đường dẫn sai, rồi in ra “Đã dừng tải: ” nối với thông báo của ngoại lệ.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra
QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1Khi await fetch(url) với một URL có domain không tồn tại, catch nhận được lỗi có error.name là gì?

Câu 2Bạn hẹn gọi abort() sau 100ms và truyền signal cho một yêu cầu mất 300ms mới có phản hồi. Điều gì sẽ xảy ra?

Câu 3Với loại lỗi nào, loadWithRetry trong bài này ném lại ngay mà không thử lại?