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ì?
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 ok là false, 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
}
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.
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
- 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 traerror instanceof TypeErrorđể phân biệt
if (!response.ok)— vớiorder.json, có phản hồi tới vàoklàfalsethrow new Error(...)—Errordo bạn tự ném
https://shop.invalid/…— không có phản hồi nào tớiTypeError: Failed to fetch— do fetch báo
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 TypeError mà fetch 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.
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
}
Đặ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.
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
}
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ỗi | Kiểm tra trong catch | Thử lại? |
|---|---|---|
| Lỗi mạng | error instanceof TypeError | Mộ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ại | Khô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);.
Kiểm tra kiến thức
Hãy trả lời từng câu hỏi một.
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?