ข้ามไปยังเนื้อหา

การทำ Outbound Requests

ตั้งแต่ Node.js 18 เป็นต้นมา fetch API มีให้ใช้ globally — ไม่ต้อง import ไม่ต้อง npm install เป็น Web Fetch API แบบเดียวกับที่ใช้ใน browser:

// @skip-verify network: illustrative — api.example.com is not a real API
// No import needed in Node 18+
const response = await fetch('https://api.example.com/data');
const data = await response.json();

นี่เป็นการปรับปรุงครั้งใหญ่จาก http.request แบบเก่าที่ต้องประกอบ chunk จาก readable stream ด้วยตนเอง

fetch คืนค่า Promise ที่ resolve เป็น Response object เรียก .json() บน response เพื่อ parse body เป็น JSON ซึ่งก็คืน Promise เช่นกัน ดังนั้นต้อง await ด้วย

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

fetch ไม่ throw เมื่อ HTTP status เป็น non-2xx คุณต้องตรวจสอบ response.ok (boolean ที่เป็น true สำหรับ status 200–299) ด้วยตนเอง:

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

ก่อน fetch การทำ outbound HTTP request ใน Node.js ใช้ http.request() จาก module node:http ที่เป็น lower-level API ใช้ stream คุณอาจพบใน codebase เก่า:

// @skip-verify network: illustrative legacy pattern against a public host
// Older pattern — collecting chunks manually
const http = require('node:http');
http.get('http://example.com', function(res) {
let body = '';
res.on('data', function(chunk) { body += chunk; });
res.on('end', function() { console.log(body); });
});

สำหรับ code ใหม่ ให้ใช้ global fetch — สะอาดกว่า ใช้ Promise และทำงานได้เหมือนกันทั้งใน browser และ Node

fetch ไม่ timeout ให้เองเลย — upstream ที่ค้างจะทำให้ await ของคุณรอตลอดไป เว้นแต่คุณจะให้ signal มันเอง AbortSignal.timeout(ms) สร้าง signal ที่ทำงานเองหลังผ่านเวลาที่กำหนด โดยไม่ต้องจัดการ AbortController/setTimeout เอง รวมกับ retry loop เล็ก ๆ และ delay แบบ exponential backoff เพื่อรับมือ upstream ที่ไม่เสถียร:

Node.js
Output (verified on Node 24)
attempt 1 failed: fetch failed
attempt 2 failed: fetch failed
succeeded: { ok: true, attempt: 3 }

AbortSignal.timeout(200) ถูกส่งเป็นตัวใหม่ทุกครั้งที่พยายาม — signal ทำงานได้ครั้งเดียวและใช้ซ้ำไม่ได้ retry loop จึงต้องสร้างตัวใหม่ทุกครั้ง ไม่ใช่ timeout เดียวที่ใช้ร่วมกันทุกครั้ง เมื่อรวมกับ response.ok มันครอบคลุมทั้งสองรูปแบบความล้มเหลว: request ที่ไม่กลับมาเลย (timeout จะ abort มัน) และ request ที่กลับมาพร้อม error status (!response.ok throw ทำให้ catch เดียวกันจัดการได้)

Promise.all(urls.map(fetch)) เริ่มทุก request พร้อมกันหมด — สำหรับ 3 URL ก็ไม่มีปัญหา แต่สำหรับ 300 URL มันอาจทำให้ upstream (หรือ limit การเชื่อมต่อ outbound ของคุณเอง) รับไม่ไหวเพราะมี 300 ตัวพร้อมกันหมด pattern worker-pool เล็ก ๆ จำกัดจำนวนที่รันพร้อมกันได้ โดยไม่ต้องใช้ library เลย:

Node.js
Output (verified on Node 24)
unbounded Promise.all: peak concurrent requests = 10
bounded (limit 3): peak concurrent requests = 3

server ที่นับจำนวนพิสูจน์ความต่างนี้ได้: version unbounded มี 10 request พร้อมกันตอน peak ในขณะที่ version bounded — worker loop worker() จำนวนคงที่ที่ดึงจาก index next เดียวกัน — ไม่เกิน limit ที่ 3 เลย ไม่ว่า URL ที่เข้าคิวอยู่จะมีกี่ตัว

header CORS (Cross-Origin Resource Sharing) สำคัญเฉพาะตอนที่ script ใน browser จาก origin หนึ่งเรียก API ที่อีก origin — การเรียก fetch/undici จาก server ไป server ไม่เคยถูก CORS บล็อกเลย เพราะ CORS เป็นกลไกที่ browser บังคับใช้ ไม่ใช่ฝั่ง server browser จะส่ง preflight request OPTIONS ก่อน cross-origin request บางประเภท และ server ต้องตอบให้ถูกต้องก่อนที่ request จริงจะถูกส่งด้วยซ้ำ:

Node.js
Output (verified on Node 24)
preflight status: 204 Allow-Methods: GET, POST
real request: { status: 200, origin: 'https://example.com', body: { ok: true } }

ทั้ง preflight response และ response จริงต่างมี Access-Control-Allow-Origin — browser เช็คมัน ทุก response แบบ cross-origin ไม่ใช่แค่ preflight ถ้าไม่มีมันใน response จริง browser จะบล็อก script ไม่ให้อ่าน body เลย แม้ว่า request จะไปถึง server และรันเสร็จแล้วก็ตาม

ตั้งแต่ Node 22 client WebSocket แบบเดียวกับใน browser ใช้ได้โดยไม่ต้อง import โค้ดฝั่ง client ชุดเดียวจึงรันได้ทั้งใน browser และบน server:

// @skip-verify network: illustrative — connects to a public WebSocket echo host
const ws = new WebSocket('wss://echo.websocket.org');
ws.addEventListener('open', function() { ws.send('hello'); });
ws.addEventListener('message', function(event) {
console.log(event.data);
ws.close();
});

การเรียก fetch ขาออกทุกครั้งคือ request ที่คุณควบคุมปลายทางไม่ได้: มันอาจช้า (ต้องมี timeout), มันอาจล้มเหลวไปเลย (ต้องมี retry), มันอาจกลับมาพร้อม error status โดยไม่ reject เลย (ต้องเช็ค response.ok) และการทำหลาย ๆ ตัวพร้อมกันอาจทำให้ฝั่งใดฝั่งหนึ่งรับไม่ไหว (ต้องจำกัด concurrency) ไม่มีอันไหนที่ fetch ทำให้อัตโนมัติเลย — แต่ละอย่างเป็นชั้นที่คุณเพิ่มรอบการเรียกเปล่า ๆ โดยตั้งใจ ให้ตรงกับสิ่งที่อาจผิดพลาดจริงสำหรับ request นั้น

การ retry พร้อม backoff เปลี่ยนความล้มเหลวชั่วคราวให้กลายเป็นความสำเร็จ แต่มันก็เพิ่ม load ให้ upstream ที่กำลังมีปัญหาอยู่แล้วในตอนที่มันรับไม่ไหวที่สุดพอดี และมันเปลี่ยนความล้มเหลวที่เร็วให้กลายเป็นช้าจากมุมมองของผู้เรียก (สามครั้งพร้อม backoff ใช้เวลานานกว่าครั้งเดียวที่ล้มเหลวเฉย ๆ มาก) การจำกัด concurrency ปกป้องทั้งสองฝั่งจาก overload แต่ limit ที่ต่ำเกินไปจะทิ้ง throughput ไปเปล่า ๆ ทั้งที่ upstream รับไหวมากกว่านั้น ไม่มีตัวเลขที่ถูกต้องสากล มีแต่ตัวที่ปรับให้เข้ากับ upstream และ workload เฉพาะนั้น

  • “fetch จะ timeout เองถ้า server ช้า” ไม่จริง — ถ้าไม่มี signal ชัดเจน connection ที่ค้างจะทำให้ await ของคุณรอไม่มีกำหนด AbortSignal.timeout(ms) คือทางแก้บรรทัดเดียว
  • “CORS ปกป้อง API ของฉันจากการถูกเรียกโดย server อื่น” CORS ถูกบังคับใช้โดย browser ที่อ่าน response header ไม่ใช่ server ปฏิเสธ connection — script ที่รันใน Node.js, mobile app หรือ curl ไม่เคยผ่านการบังคับใช้ CORS เลย มันจำกัดแค่ JavaScript ที่รันใน browser เท่านั้น
  • “Promise.all(urls.map(fetch)) คือวิธีที่เร็วในการ fetch หลาย URL” มันเร็วจนกว่าจำนวน URL จะมากพอที่จะทำให้ upstream รับไม่ไหว (หรือ outbound connection ของคุณเองหมด) — ที่ scale นั้น bounded concurrency ไม่ใช่แค่มารยาท แต่มักเป็นสิ่งที่ทำให้ batch เสร็จได้เลย แทนที่จะ timeout หรือโดน rate-limit

โค้ดที่ AI เขียนเพื่อ fetch “ทั้งหมด” มักจะใช้ Promise.all(urls.map(fetch)) โดยสัญชาตญาณ — มันเป็นวิธีที่สั้นที่สุดในการเขียน “fetch พวกนี้” และมันดันผิดพอดีทันทีที่ list ไม่เล็กและไม่คงที่

🐛 หา bug ในโค้ดนี้

async function fetchAllUserAvatars(userIds) {
const urls = userIds.map(function (id) { return '/avatars/' + id + '.png'; });
const responses = await Promise.all(urls.map(function (url) { return fetch(url); }));
return responses.map(function (r) { return r.url; });
}
// Called with a list of 500 user IDs from a batch job
fetchAllUserAvatars(fiveHundredUserIds);
เฉลย

Promise.all(urls.map(fetch)) ยิง request ทั้ง 500 ตัวพร้อมกันในจังหวะเดียวกันเป๊ะ — ไม่มี server หรือ connection pool ขาออกไหนถูกออกแบบมาให้รับ load แบบนั้นเป็นเรื่องปกติ ผลลัพธ์ที่น่าจะเกิดคือ upstream rate-limit หรือ error ออกมา, process หมด socket ที่ใช้ได้ หรือแค่ latency แย่ลงอย่างเห็นได้ชัดสำหรับทุกหนึ่งใน 500 request เทียบกับการรันจำนวนจำกัดทีละชุด โค้ดนี้ยังไม่เช็ค response.ok เลยด้วย ดังนั้น batch ของ response 4xx/5xx จะดูเหมือน batch ของความสำเร็จ จนกว่าจะมีอะไรพยายามใช้ body (ที่เป็น error) เป็นข้อมูลรูปภาพทีหลัง ทางแก้คือ pattern จากบทนี้: worker pool เล็ก ๆ ที่มี concurrency limit คงที่ และเช็ค response.ok ทุก request

ตั้งแต่ Node.js version ใดที่ fetch มีให้ใช้ globally โดยไม่ต้อง import?
response.ok คืนค่าอะไรเมื่อ HTTP status เป็น 404?
เมื่อใดที่ fetch จะ throw rejection (catch block ทำงาน)?
ทำไม retry loop ต้องเรียก AbortSignal.timeout(200) ใหม่ทุกครั้งที่พยายาม แทนที่จะสร้าง signal เดียวก่อน loop แล้วใช้ซ้ำ?
สิ่งนี้พิมพ์อะไร เมื่อมี server ที่นับจำนวนและ 10 URL?
const results = await Promise.all(urls.map(function (url) { return fetch(url); }));
console.log('peak concurrent requests =', maxInFlight);
mobile app (ไม่ใช่ browser) เรียก JSON API โดยตรงด้วย fetch CORS จะบล็อก request นี้ไหมถ้า server ไม่มี header Access-Control-Allow-Origin?