Rate Limit และ Retry เมื่อเรียก AI API

ใครที่เอา AI API ขึ้น production มาสักพักจะเจออาการคุ้นเคย คือระบบทำงานดีตอนทดสอบ แต่พอมีผู้ใช้พร้อมกันเยอะ ๆ ก็เริ่มได้ HTTP 429 หรือ 5xx กลับมาเป็นระยะ ถ้าโค้ดของเราโยน error ใส่ผู้ใช้ทันที หรือแย่กว่านั้นคือ retry ทันทีแบบวนไม่หยุด ปัญหาจะใหญ่ขึ้นเรื่อย ๆ บทความนี้จะอธิบายว่า error แต่ละแบบหมายถึงอะไร ควร retry อย่างไรด้วย exponential backoff และ jitter ทำไม idempotency สำคัญ และเมื่อไรควรย้ายงานไปเข้าคิว พร้อมตัวอย่างโค้ด PHP ที่นำไปใช้ได้
รู้จัก error ที่ควรและไม่ควร retry
ก่อนเขียน retry ต้องแยกให้ออกก่อนว่า error ไหนเป็นปัญหาชั่วคราวที่ลองใหม่แล้วอาจหาย และ error ไหนลองกี่ครั้งก็ไม่หาย
| สถานะ | ความหมายโดยทั่วไป | ควร retry ไหม |
|---|---|---|
| 429 Too Many Requests | เรียกถี่หรือใช้ token เกินโควตาต่อช่วงเวลา | ใช่ รอตาม Retry-After หรือ backoff |
| 500, 502, 503, 504 | ฝั่งผู้ให้บริการมีปัญหาหรือ overload ชั่วคราว | ใช่ ด้วย backoff |
| Timeout / connection error | เครือข่ายหรือ server ตอบช้าเกิน | ใช่ แต่ระวังเรื่องงานซ้ำ |
| 400 Bad Request | request ผิดรูปแบบ หรือ input ยาวเกินที่โมเดลรับได้ | ไม่ ต้องแก้ request |
| 401, 403 | key ผิด หมดอายุ หรือไม่มีสิทธิ์ | ไม่ ต้องแจ้งทีม |
ผู้ให้บริการบางรายใช้ status code เฉพาะเพิ่มเติม เช่น สถานะที่บอกว่าระบบ overload หรือแนบรายละเอียดใน body ของ error ให้ตรวจเอกสารของผู้ให้บริการที่ใช้อยู่ แล้วปรับรายการ status ที่ retry ได้ให้ตรง
สิ่งสำคัญอีกอย่างคือ header Retry-After ถ้า server ส่งมา แปลว่าเขาบอกเราตรง ๆ แล้วว่าควรรอนานเท่าไร ค่านี้อาจเป็นจำนวนวินาทีหรือเป็นวันที่ตามมาตรฐาน HTTP เราควรเคารพค่านี้ก่อนสูตรของเราเองเสมอ
Exponential backoff และ jitter
วิธีที่ผิดคือ retry ทันทีหลัง error เพราะถ้า server กำลัง overload เราจะซ้ำเติมมันอีก วิธีที่ถูกคือรอนานขึ้นเป็นทวีคูณในแต่ละรอบ เช่น 1, 2, 4, 8 วินาที เรียกว่า exponential backoff และกำหนดเพดานสูงสุดไว้ด้วย
แต่ backoff อย่างเดียวยังไม่พอ ลองนึกภาพ worker ร้อยตัวโดน 429 พร้อมกัน แล้วทุกตัวรอ 1 วินาทีเท่ากันเป๊ะ พอครบเวลาก็ยิงพร้อมกันอีก ปัญหาก็วนกลับมาเหมือนเดิม การเพิ่ม jitter คือการสุ่มเวลารอเพื่อกระจาย request ออกไป วิธีที่นิยมคือ "full jitter" คือสุ่มค่าระหว่าง 0 ถึงค่า backoff ของรอบนั้น
delay = random(0, min(cap, base * 2^attempt))
attempt 0 -> สุ่ม 0 ถึง 1 วินาที
attempt 1 -> สุ่ม 0 ถึง 2 วินาที
attempt 2 -> สุ่ม 0 ถึง 4 วินาที
attempt 3 -> สุ่ม 0 ถึง 8 วินาที (และไม่เกิน cap)
ตัวอย่าง PHP: retry อย่างมีขอบเขต
ตัวอย่างนี้ใช้ cURL ล้วน ๆ endpoint ชื่อโมเดล และ key อ่านจาก environment ส่วนรูปแบบ JSON ของ request และ response รวมถึงชื่อ header สำหรับ key ให้ปรับตามเอกสารของผู้ให้บริการ
<?php
declare(strict_types=1);
final class RetryableHttpException extends RuntimeException
{
public function __construct(string $message, public readonly ?int $retryAfter = null)
{
parent::__construct($message);
}
}
function parseRetryAfter(?string $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if (ctype_digit($value)) {
return (int) $value;
}
$ts = strtotime($value);
return $ts === false ? null : max(0, $ts - time());
}
/** @return array<string, mixed> */
function callLlmOnce(array $payload): array
{
$url = (string) getenv('LLM_API_URL');
$key = (string) getenv('LLM_API_KEY');
$retryAfter = null;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $key,
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$retryAfter): int {
if (stripos($line, 'Retry-After:') === 0) {
$retryAfter = parseRetryAfter(trim(substr($line, 12)));
}
return strlen($line);
},
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($errno !== 0) {
throw new RetryableHttpException('Network error: ' . $errno);
}
if ($status === 429 || $status >= 500) {
throw new RetryableHttpException('HTTP ' . $status, $retryAfter);
}
if ($status >= 400) {
throw new RuntimeException('HTTP ' . $status . ' (ไม่ retry)');
}
return json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR);
}
/** @return array<string, mixed> */
function callLlmWithRetry(array $payload, int $maxAttempts = 5, float $baseSec = 1.0, float $capSec = 30.0): array
{
for ($attempt = 0; ; $attempt++) {
try {
return callLlmOnce($payload);
} catch (RetryableHttpException $e) {
if ($attempt + 1 >= $maxAttempts) {
throw $e;
}
$backoff = min($capSec, $baseSec * (2 ** $attempt));
$delay = $e->retryAfter !== null
? min((float) $e->retryAfter, $capSec * 2)
: (mt_rand() / mt_getrandmax()) * $backoff; // full jitter
usleep((int) ($delay * 1_000_000));
}
}
}
$result = callLlmWithRetry([
'model' => getenv('LLM_MODEL'),
'messages' => [['role' => 'user', 'content' => 'สรุปข้อความนี้ให้สั้น']],
'max_tokens' => 300,
]);
จุดที่ควรสังเกต:
- จำกัดจำนวนครั้ง (
$maxAttempts) และเวลารอสูงสุด ไม่มี retry ไม่รู้จบ - ถ้ามี
Retry-Afterใช้ค่านั้นแทนสูตร แต่ยังมีเพดานกันกรณีค่าแปลก ๆ - error กลุ่ม 4xx ที่ไม่ใช่ 429 จะโยนออกไปทันที ไม่เสียเวลาลองซ้ำ
- ไม่ log key หรือ header ทั้งชุด
ข้อควรระวังใน web request: ถ้า retry หลายรอบภายใน request ของผู้ใช้ เวลารวมอาจนานจนเกิน timeout ของ PHP-FPM หรือ reverse proxy ใน web request ควรตั้ง $maxAttempts ต่ำ ๆ แล้วย้ายงานยาวไปไว้ในคิว
Idempotency: retry แล้วต้องไม่ทำงานซ้ำ
การเรียก LLM เพื่อถามคำถามเฉย ๆ ทำซ้ำได้ไม่เสียหาย นอกจากเสียเงินเพิ่ม แต่ระบบจริงมักมีขั้นตอนต่อจากคำตอบ เช่น บันทึกบทสรุปลงฐานข้อมูล ส่งอีเมล หรือหักเครดิตผู้ใช้ ถ้า timeout เกิดหลังจากที่ server ประมวลผลไปแล้ว เราจะไม่รู้เลยว่ารอบแรกสำเร็จหรือไม่ การ retry อาจทำให้เกิดผลซ้ำ
แนวทางที่ใช้กันคือ:
- สร้าง idempotency key ต่องาน (เช่น UUID หรือ hash ของ input) แล้วเก็บผลลัพธ์ผูกกับ key นั้น ก่อนทำงานให้เช็กว่ามีผลอยู่แล้วหรือยัง
- ถ้าผู้ให้บริการรองรับ header สำหรับ idempotency ให้ส่งไปด้วย แต่ไม่ใช่ทุกเจ้ารองรับ ต้องตรวจเอกสาร
- ใช้ unique constraint ในฐานข้อมูลเป็นด่านสุดท้าย
CREATE TABLE ai_jobs (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- บันทึกงานใหม่ ถ้า key ซ้ำจะไม่สร้างแถวใหม่
INSERT INTO ai_jobs (idempotency_key) VALUES (:key)
ON CONFLICT (idempotency_key) DO NOTHING;
ย้ายงานเข้าคิว
งานที่ไม่ต้องตอบผู้ใช้ทันที เช่น สรุปเอกสารที่อัปโหลด แปลบทความ หรือสร้าง embedding ทั้งชุด ไม่ควรทำใน web request เลย ควรบันทึกงานลงคิวแล้วตอบผู้ใช้ว่า "กำลังประมวลผล" จากนั้นให้ worker ทยอยทำ ข้อดีคือ:
- คุมความเร็วได้ เช่น จำกัด worker ไว้ไม่กี่ตัวให้ไม่เกิน rate limit ของบัญชี
- retry ได้นานกว่าเดิมโดยไม่ทำให้หน้าเว็บค้าง
- เมื่อผู้ให้บริการล่มชั่วคราว งานไม่หาย แค่รอในคิว
ใน Yii2 สามารถใช้ extension yiisoft/yii2-queue ซึ่งรองรับ driver หลายแบบ เช่น database หรือ Redis และมีแนวคิด job ที่ retry ได้ หรือถ้าต้องการเรียบง่าย ใช้ตาราง ai_jobs ข้างบนร่วมกับ console command ที่ดึงงานด้วย SELECT ... FOR UPDATE SKIP LOCKED ของ PostgreSQL ก็ใช้งานได้ดี
-- worker ดึงงานทีละชิ้น ไม่ชนกันแม้รันหลายตัว
UPDATE ai_jobs SET status = 'running', attempts = attempts + 1
WHERE id = (
SELECT id FROM ai_jobs
WHERE status = 'pending' AND attempts < 5
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, idempotency_key;
Checklist ก่อนขึ้น production
- แยก error ที่ retry ได้ (429, 5xx, network) ออกจากที่ไม่ควร retry (400, 401, 403)
- ใช้ exponential backoff พร้อม jitter และมีเพดานทั้งจำนวนครั้งและเวลารอ
- เคารพ
Retry-Afterเมื่อ server ส่งมา - งานที่มีผลข้างเคียงต้องมี idempotency key
- งานยาวหรือไม่เร่งด่วนย้ายไปไว้ในคิว
- log จำนวนครั้งที่ retry และสถานะสุดท้าย เพื่อดูแนวโน้มว่าเริ่มชน limit บ่อยขึ้นหรือไม่
- มีข้อความที่เป็นมิตรกับผู้ใช้เมื่อ retry ครบแล้วยังไม่สำเร็จ
สรุป
- 429 และ 5xx มักเป็นปัญหาชั่วคราว ควร retry แต่ 4xx อื่น ๆ ต้องแก้ที่ request
- exponential backoff ช่วยไม่ซ้ำเติม server ส่วน jitter ช่วยกระจาย request ไม่ให้ยิงพร้อมกัน
Retry-Afterคือคำแนะนำโดยตรงจาก server ให้ใช้ก่อนสูตรของเรา- idempotency ทำให้ retry ได้อย่างปลอดภัยโดยไม่เกิดผลซ้ำ
- คิวงานช่วยคุมความเร็ว แยกงานยาวออกจาก web request และทำให้ระบบทนต่อการล่มชั่วคราว
ลองทำ
สร้างไฟล์ PHP เล็ก ๆ ที่จำลอง API ปลายทาง โดยให้ตอบ 429 พร้อม Retry-After: 2 สองครั้งแรก แล้วตอบ 200 ในครั้งที่สาม (รันด้วย php -S localhost:8080 mock.php และเก็บตัวนับไว้ในไฟล์ชั่วคราว) จากนั้นชี้ LLM_API_URL ไปที่ mock นี้ แล้วเรียก callLlmWithRetry() พร้อมพิมพ์เวลาที่รอแต่ละรอบ ตรวจว่าโค้ดรอตาม Retry-After จริง แล้วลองเปลี่ยน mock ให้ตอบ 500 โดยไม่มี header เพื่อดูว่า jitter ทำให้เวลารอแต่ละรอบไม่เท่ากัน
ความคิดเห็น (0)
ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ