DEV Community

Cover image for API Retry และ Exponential Backoff: รูปแบบที่ได้ผลจริง
Thanawat Wongchai
Thanawat Wongchai

Posted on Originally published at apidog.com

API Retry และ Exponential Backoff: รูปแบบที่ได้ผลจริง

ออกแบบ Retry Logic สำหรับ API ให้พร้อมใช้งานจริง

การเรียกใช้ API การชำระเงินล้มเหลวตอนตี 2 เพราะเครือข่ายขัดข้อง ติด rate limit หรือเซิร์ฟเวอร์ล่ม? คำตอบจะเป็นตัวตัดสินว่าการลองใหม่จะช่วยบันทึกรายการ หรือทำให้ลูกค้าถูกเรียกเก็บเงินซ้ำ

ลองใช้ Apidog วันนี้

การลองใหม่ (retry) เป็น resilience pattern ที่ใช้บ่อยที่สุดใน distributed systems และเป็นสิ่งที่มักออกแบบผิดพลาดที่สุด การวนลูปเรียก HTTP ใหม่อาจดูเหมือนการเขียนโปรแกรมเชิงป้องกัน แต่หากไม่มี backoff และ jitter การหยุดทำงาน 30 วินาทีอาจกลายเป็น 30 นาที เพราะลูกค้านับพันรายส่งคำขอไปยังเซิร์ฟเวอร์ที่กำลังมีปัญหาพร้อมกัน

บทความนี้ครอบคลุมแนวทางที่ production systems ใช้จริง:

  • สถานะใดควรลองใหม่และสถานะใดควร fail fast
  • Exponential backoff พร้อม full jitter
  • การรองรับ Retry-After
  • Idempotency keys สำหรับคำขอ POST
  • Retry budgets และ circuit breakers
  • การจำลองข้อผิดพลาด 429 และ 503 ด้วย mock server ของ Apidog

Retry logic ที่ไม่เคยทดสอบกับเซิร์ฟเวอร์ล้มเหลว ยังเป็นเพียงการคาดเดา ไม่ใช่การออกแบบ

ทำไม Naive Retries ทำให้การหยุดทำงานแย่ลง

สมมติว่าบริการรองรับ 1,000 คำขอต่อวินาที และสะดุดเป็นเวลา 5 วินาที หากลูกค้าทุกคนลองใหม่ทันทีคนละ 3 ครั้ง ทราฟฟิกจะเพิ่มจาก 1,000 เป็น 4,000 คำขอต่อวินาที และทั้งหมดพุ่งไปยังเซิร์ฟเวอร์ที่มีปัญหาอยู่แล้ว

รูปแบบนี้เรียกว่า retry storm ส่วนการที่ลูกค้าจำนวนมากกลับมาส่งคำขอพร้อมกันหลังเซิร์ฟเวอร์ฟื้นตัวเรียกว่า thundering herd โดย SRE ของ Google ระบุว่าการลองใหม่โดยไม่มี backoff สามารถเพิ่มภาระงานในช่วงที่ระบบรับไม่ไหว และทำให้บริการหยุดทำงานต่อแม้จะแก้ไขปัญหาเดิมแล้วก็ตาม

ข้อผิดพลาดในการออกแบบที่พบบ่อยมี 2 อย่าง:

  1. ไม่มีการหน่วงเวลา — ลองใหม่ทันทีและเพิ่มภาระงานในจังหวะที่แย่ที่สุด
  2. ใช้ fixed delay — หากลูกค้าทุกคนรอ 1 วินาทีเท่ากัน พวกเขาจะกลับมาพร้อมกันเป็นคลื่น

ทางแก้ไม่ใช่ “ห้ามลองใหม่” แต่คือการลองใหม่อย่างเลือกสรร ใช้ delay ที่เพิ่มขึ้นแบบสุ่ม และกำหนดขีดจำกัดภาระงานเพิ่มเติมไว้อย่างชัดเจน

ลองใหม่เฉพาะความล้มเหลวชั่วคราว

ก่อนคำนวณ backoff ให้กำหนด decision table ก่อน การลองใหม่คำขอที่เซิร์ฟเวอร์ปฏิเสธว่าไม่ถูกต้องจะสิ้นเปลืองทรัพยากรและทำให้ log มีสัญญาณรบกวน

สถานะที่ควรลองใหม่

สัญญาณ ความหมาย
429 Too Many Requests ติด rate limit ควรถอยออกไปแล้วค่อยกลับมาใหม่
502 Bad Gateway Upstream hop ส่งข้อมูลผิดพลาด มักเป็นความล้มเหลวชั่วคราว
503 Service Unavailable เซิร์ฟเวอร์โอเวอร์โหลดหรือกำลังรีสตาร์ต
504 Gateway Timeout Upstream dependency ตอบช้าเกินไป
Connection reset, DNS error, socket timeout คำขออาจยังไปไม่ถึงเซิร์ฟเวอร์

504 Gateway Timeout ต้องระวังเป็นพิเศษ เพราะต้นทางอาจประมวลผลคำขอเสร็จแล้ว แม้ gateway จะหยุดรอไปก่อน ความแตกต่างนี้สำคัญมากเมื่อพิจารณาเรื่อง idempotency

สถานะที่ควร fail fast

สัญญาณ ความหมาย
400 Bad Request Payload ไม่ถูกต้อง และจะยังไม่ถูกต้องในการลองครั้งถัดไป
401 Unauthorized Credential ไม่ถูกต้องหรือหมดอายุ ควรรีเฟรช token
403 Forbidden ไม่มีสิทธิ์ การลองใหม่ไม่ได้เพิ่มสิทธิ์
422 Unprocessable Entity Validation ล้มเหลว ต้องแก้ข้อมูลก่อน

กฎง่าย ๆ คือ:

ลองใหม่เมื่อปัญหาเกิดจากสถานะของเซิร์ฟเวอร์หรือเครือข่าย และ fail fast เมื่อปัญหาเกิดจากคำขอของเรา

429 อยู่ตรงกลาง: ลองใหม่ได้ แต่การเกิดซ้ำบ่งชี้ว่าควรแก้ rate limiting ด้วย client-side throttling หรือแคช ไม่ใช่เพิ่มจำนวน retry อย่างเดียว

Exponential Backoff และความสำคัญของ Jitter

Exponential backoff ทำให้การลองแต่ละครั้งรอนานขึ้นเป็นลำดับ:

delay = base * 2^retry_count
Enter fullscreen mode Exit fullscreen mode

หาก base = 500 มิลลิวินาที จะได้ delay เป็น 0.5, 1, 2, 4 และ 8 วินาที ควรกำหนดเพดานเพื่อไม่ให้คำขอหนึ่งรายการรอนานหลายนาที:

delay = min(cap, base * 2^retry_count)
Enter fullscreen mode Exit fullscreen mode

อย่างไรก็ตาม exponential backoff ธรรมดายังมีปัญหาการซิงโครไนซ์ หากไคลเอ็นต์ 5,000 รายล้มเหลวพร้อมกัน พวกเขาจะกลับมาที่ t=0.5s, t=1s และ t=2s พร้อมกันอยู่ดี

ใช้ Full Jitter

Jitter ทำลายการซิงโครไนซ์ด้วยการสุ่ม delay:

delay = random_between(0, min(cap, base * 2^retry_count))
Enter fullscreen mode Exit fullscreen mode

Full jitter กระจายไคลเอ็นต์อย่างสม่ำเสมอทั่วช่วงเวลา จึงลด spike ของคำขอและช่วยให้เซิร์ฟเวอร์รับภาระได้ราบรื่นขึ้น การวิเคราะห์ของ AWS พบว่า full jitter ให้จำนวนคำขอรวมต่ำและเวลาเสร็จสิ้นสั้น เมื่อเทียบกับ plain backoff และ equal jitter

AWS ยังทดสอบ equal jitter และ decorrelated jitter ด้วย โดย full jitter และ decorrelated jitter ทำได้ดีกว่าในหลายกรณี แต่ full jitter เป็นวิธีที่ง่ายที่สุดในการเขียนให้ถูกต้อง จึงเหมาะเป็นค่าเริ่มต้น เว้นแต่ข้อมูลการวัดผลของระบบจะชี้ไปทางอื่น

เคารพ Retry-After

Backoff คือการคาดเดาของไคลเอ็นต์ แต่บางครั้งเซิร์ฟเวอร์จะบอกเวลาที่เหมาะสมมาให้โดยตรง:

HTTP/1.1 429 Too Many Requests
Retry-After: 12
Enter fullscreen mode Exit fullscreen mode

เมื่อมี Retry-After ให้ใช้ค่านี้แทน backoff ที่คำนวณเอง เพราะเซิร์ฟเวอร์รู้ว่า rate-limit window จะรีเซ็ตหรือการบำรุงรักษาจะเสร็จเมื่อใด

อย่างไรก็ดี ควรทำสิ่งต่อไปนี้เสมอ:

  • ตรวจสอบและ parse ค่า header
  • จำกัด delay ด้วย cap ของไคลเอ็นต์
  • จำกัดจำนวน retry สูงสุด
  • ป้องกันค่าที่ผิดปกติ เช่น Retry-After: 86400 ไม่ให้ worker ค้างทั้งวัน

Idempotency: เงื่อนไขสำคัญของการ Retry POST

โดยทั่วไป GET, PUT และ DELETE เป็น idempotent ตามสัญญา การส่งซ้ำทำให้ระบบอยู่ในสถานะเดียวกัน แต่ POST ไม่จำเป็นต้องเป็นเช่นนั้น

หาก POST /v1/payments timeout หลังจากเซิร์ฟเวอร์ประมวลผลเสร็จแล้ว การ retry อาจสร้างการชำระเงินรายการที่สองได้

วิธีแก้คือ idempotency key ซึ่งเป็น ID ที่ไคลเอ็นต์สร้างขึ้นและส่งเป็น header:

Idempotency-Key: <unique-client-generated-id>
Enter fullscreen mode Exit fullscreen mode

เซิร์ฟเวอร์จะจัดเก็บ key พร้อม response แรก และส่ง response เดิมกลับมาเมื่อพบคำขอซ้ำ แนวทางนี้ถูกใช้โดย Stripe และ API การชำระเงินหรือ provisioning จำนวนมาก

กฎสำคัญมี 2 ข้อ:

  1. การดำเนินการเดียวกันต้องใช้ key เดิม — retry ทุกครั้งของการชำระเงินเดียวกันต้องใช้ key เดิม ส่วนการดำเนินการใหม่ต้องสร้าง key ใหม่
  2. สร้าง key ก่อนส่งครั้งแรก — อย่าสร้างภายใน retry loop เพราะแต่ละครั้งจะถูกมองว่าเป็นการดำเนินการใหม่

หาก API ไม่มี idempotency key อย่า retry การเขียนที่ไม่ใช่ idempotent โดยอัตโนมัติ ให้ส่งความล้มเหลวกลับไป และให้มนุษย์หรือ reconciliation job เป็นผู้ตัดสินใจ

Retry Budgets และ Circuit Breakers

Backoff กำหนดว่า retry จะเกิดขึ้นเมื่อใด แต่ไม่ได้จำกัดภาระงานรวม หาก API gateway retry 3 ครั้ง และ service client retry อีก 3 ครั้ง การคลิกเพียงครั้งเดียวอาจกลายเป็น 9 คำขอ

Retry Budgets

แทนที่จะกำหนดเพียง “3 ครั้งต่อคำขอ” ให้กำหนดงบประมาณระดับระบบ เช่น:

Retry ต้องเพิ่มทราฟฟิกไม่เกิน 10% ภายใน sliding window

เมื่อใช้งบประมาณหมด คำขอใหม่จะถูกส่งกลับทันที วิธีนี้จำกัด retry amplification แม้มีคำขอล้มเหลวพร้อมกันจำนวนมาก โดย Linkerd และ Envoy รองรับแนวคิดนี้ในระดับการกำหนดค่า

Circuit Breakers

Circuit breaker ติดตามอัตราความล้มเหลวของแต่ละ downstream dependency เมื่อเกิน threshold เบรกเกอร์จะเปิดและทำให้คำขอล้มเหลวทันทีโดยไม่เรียกเครือข่าย

หลัง cooldown จะอนุญาตคำขอทดสอบจำนวนเล็กน้อย:

  • หาก dependency ฟื้นตัว เบรกเกอร์จะปิด
  • หากยังล้มเหลว เบรกเกอร์จะเปิดต่อ

Backoff ช่วยชะลอ retry อย่างสุภาพ ส่วน circuit breaker ช่วยหยุด retry เมื่อเห็นว่าปลายทางยังไม่พร้อม ระบบที่จริงจังควรใช้ทั้งสองอย่างร่วมกัน

ตัวอย่าง Python ที่พร้อมใช้ใน Production

ตัวอย่างนี้รวม pattern สำคัญทั้งหมด:

  • กรองสถานะที่ retry ได้
  • ใช้ full jitter
  • รองรับ Retry-After
  • ใช้ idempotency key
  • จำกัดจำนวน retry อย่างแน่นอน
import random
import time
import uuid
import requests

RETRYABLE = {429, 502, 503, 504}
BASE = 0.5     # seconds
CAP = 30.0     # ceiling on any single delay
MAX_RETRIES = 5

def create_payment(payload):
    idempotency_key = str(uuid.uuid4())  # one key per logical payment
    headers = {"Idempotency-Key": idempotency_key}

    for retry_count in range(MAX_RETRIES + 1):
        try:
            resp = requests.post(
                "https://api.acmepay.com/v1/payments",
                json=payload, headers=headers, timeout=10,
            )
            if resp.status_code < 400:
                return resp.json()
            if resp.status_code not in RETRYABLE:
                resp.raise_for_status()  # 400/401/403/422: fail fast
            retry_after = resp.headers.get("Retry-After")
        except (requests.ConnectionError, requests.Timeout):
            retry_after = None  # network fault: fall through to backoff

        if retry_count == MAX_RETRIES:
            raise RuntimeError("payment failed after all retries")

        if retry_after and retry_after.isdigit():
            delay = min(CAP, float(retry_after))
        else:
            delay = random.uniform(0, min(CAP, BASE * 2 ** retry_count))
        time.sleep(delay)
Enter fullscreen mode Exit fullscreen mode

สิ่งที่ควรตรวจสอบ:

  • Key ถูกสร้างครั้งเดียวและอยู่นอกลูป
  • Retry-After มีความสำคัญกว่า backoff ที่คำนวณเอง
  • Delay ยังอยู่ภายใต้ CAP
  • สถานะที่ retry ไม่ได้จะ fail fast
  • จำนวน retry ถูกจำกัดด้วย MAX_RETRIES

หากใช้ JavaScript ไลบรารี axios-retry ก็รองรับ pattern เดียวกันผ่าน retryCondition และ retryDelay แต่ decision table ยังคงต้องออกแบบเหมือนเดิม

ทดสอบ Retry Logic ก่อน Production

หลายทีมทดสอบเฉพาะ happy path และปล่อยให้เส้นทาง 503 ทำงานครั้งแรกระหว่าง outage จริง วิธีที่ดีกว่าคือจำลองความล้มเหลวไว้ตั้งแต่ต้น

1. จำลองความล้มเหลวด้วย Mock Server

Apidog Smart Mock ช่วยสร้าง endpoint เช่น /v1/payments และกำหนด response ได้ เช่น:

  • คืน 503 สองครั้งแรก แล้วคืน 200 ในครั้งที่สาม
  • คืน 429 พร้อม Retry-After: 5
  • เพิ่ม delay 15 วินาทีเพื่อทดสอบ timeout

ชี้ไคลเอ็นต์ไปยัง mock URL แล้วตรวจสอบว่า retry loop จัดการแต่ละสถานการณ์ได้ถูกต้อง โดยไม่ต้องรอ outage จริง

2. ยืนยันผลด้วย Test Scenarios

ใช้ test scenarios เพื่อเชื่อม request เข้ากับ assertions และการตรวจสอบเวลา ตรวจสอบอย่างน้อยว่า:

  • คำขอสำเร็จในที่สุด
  • เวลารวมอยู่ในช่วง backoff ที่คาดไว้
  • มีการสร้าง resource เพียงรายการเดียว
  • idempotency key ป้องกันการสร้างข้อมูลซ้ำ
  • scenario ทำงานใน CI ทุกครั้งที่มี commit

นี่คือความแตกต่างระหว่าง “เราเพิ่ม retry แล้ว” กับ “เรายืนยันแล้วว่าไคลเอ็นต์รับมือกับ rate limit และ partial failure ได้จริง”

FAQ

ควร retry รหัส 429 หรือไม่?

ควร retry และ 429 เป็นสถานะที่เซิร์ฟเวอร์มักบอกแนวทางกลับมา อ่าน Retry-After และรออย่างน้อยตามค่าที่ระบุ หากไม่มี header ให้ใช้ exponential backoff พร้อม jitter

หาก 429 เกิดซ้ำ ให้แก้ปัญหาด้วย client-side throttling หรือแคช เพราะไม่ควรถือว่าเป็นพฤติกรรมปกติของระบบ

Full jitter คืออะไร?

Full jitter คือการสุ่ม delay อย่างสม่ำเสมอระหว่างศูนย์กับ exponential cap:

random(0, min(cap, base * 2^n))
Enter fullscreen mode Exit fullscreen mode

ช่วยป้องกันคลื่น retry ที่ซิงโครไนซ์จากไคลเอ็นต์จำนวนมาก และจากการจำลองของ AWS ทำได้ดีกว่า plain backoff และ equal jitter ในด้านจำนวนคำขอรวมและเวลาเสร็จสิ้น

การ retry คำขอ POST ปลอดภัยหรือไม่?

ปลอดภัยเมื่อคำขอนั้น idempotent ในทางปฏิบัติ ซึ่งสำหรับ POST มักหมายถึงการส่ง idempotency key ที่เซิร์ฟเวอร์ใช้กำจัดคำขอซ้ำ

หากไม่มี key การ retry หลัง timeout อาจสร้างการชำระเงิน คำสั่งซื้อ หรือ record ซ้ำ เพราะเซิร์ฟเวอร์อาจประมวลผลคำขอไปแล้ว แม้ไคลเอ็นต์จะมองว่าเป็นความล้มเหลว

หลักการเดียวกันนี้ใช้กับ AI agents ที่เรียก write API ได้แก่:

  • Keyed writes
  • Capped retries
  • Circuit breakers

ควร retry กี่ครั้ง?

โดยทั่วไป 3–5 ครั้งเพียงพอสำหรับความล้มเหลวชั่วคราวส่วนใหญ่ หลังจากนั้นอัตราความสำเร็จมักเพิ่มขึ้นน้อยมาก แต่ภาระงานและ latency ยังเพิ่มต่อไป

ควรจับคู่ per-request limit กับ retry budget ระดับระบบ เช่น จำกัดให้ retry เพิ่มทราฟฟิกไม่เกิน 10% หาก dependency ยังล้มเหลวหลัง retry ครั้งสุดท้าย นั่นควรเป็นหน้าที่ของ circuit breaker ไม่ใช่การเพิ่มจำนวน retry ต่อไป

ลิงก์อ้างอิง

Top comments (0)