Timeouts & retries
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”pkg/resilience/interceptor.go — package เล็ก ๆ ของ grpc.UnaryClientInterceptor และ helper DialOptions() ที่ gRPC client ทุกตัวในระบบนี้ route ผ่าน สอง interceptor ในบทนี้: TimeoutInterceptor จำกัดแต่ละ call ด้วย deadline จาก context.WithTimeout และ RetryInterceptor retry call ที่ล้มเหลวด้วย transient code (Unavailable, DeadlineExceeded) สองสามครั้ง ด้วย exponential backoff และ jitter — แต่เฉพาะ method ที่ idempotent เท่านั้น จากนั้นแก้ที่ call site ที่มีอยู่ทั้งสองที่บรรทัดเดียว: dial options ของ Register*ServiceHandlerFromEndpoint ของ gateway grpc-gateway → และ grpc.NewClient ไป Catalog ของ Order The gRPC Server → ต่างเลิกส่ง insecure.NewCredentials() เปล่า ๆ แล้วส่ง resilience.DialOptions() แทน
นี่คือ module ที่ The gRPC Server → สัญญาไว้ตอนยอมรับอย่างตั้งใจว่า “การเรียก Catalog สด ๆ แทนที่จะ cache ข้อมูลราคาไว้ทำให้ความแม่นยำของราคาฟรี แลกกับ dependency ระหว่างสองเซอร์วิสตอน runtime — trade-off ที่คอร์สนี้ harden ทีหลังใน Resilience” และตอนนี้คือ “ทีหลัง” ที่ว่า ฝั่ง async hop ไม่ต้องการสิ่งนี้ เพราะ Kafka redelivery Producer & Consumer → กับ ack/retry/dead-letter ของ RabbitMQ Acks, Retry & Dead Letters → ทำให้ path ของ event และ work queue เป็น at-least-once อยู่แล้ว ตัวที่ยังไม่มี timeout และไม่มี retry จนถึงตอนนี้คือ call gRPC แบบ synchronous ที่ caller ต้อง block รอ network round trip สด ๆ ไปอีก process ต่างหาก
gRPC call ที่ไม่มี deadline คือ call ที่ block ได้ตลอดกาล ทุก invoker(ctx, ...) ในระบบนี้จนถึงตอนนี้รันบน context.Background() ที่ไม่เคยหมดอายุ — ถ้า Catalog รับ TCP connection ของ Order แล้วค้าง (deadlock, connection pool เต็ม, GC pause ที่ไม่จบ), CreateOrder ของ Order รอ call นั้นโดยไม่มีขอบเขตบน และทุก request ที่กองอยู่ข้างหลังก็รอด้วย นั่นคือวิธีที่ dependency ช้าตัวเดียวลุกลามเป็น outage ทั้งระบบ ไม่ใช่เพราะ dependency return error แต่เพราะ ไม่ return อะไรเลย และไม่มีใครตั้งขีดจำกัดการรอไว้
TimeoutInterceptor ตั้งขีดจำกัดนั้นจากศูนย์กลาง ด้วยการเอา context.WithTimeout ครอบทุก call ขาออก Catalog ที่ค้างจึงกินเวลา caller อย่างมากแค่ defaultTimeout แล้วคืน codes.DeadlineExceeded สะอาด ๆ ที่ caller จัดการต่อได้จริง เป็นวินัยเดียวกับ timeout ของ http.Server ที่ grpc-gateway → ใช้กับ HTTP ขาเข้าไปแล้ว เพียงแต่ย้ายมาใช้กับ gRPC ขาออก
Retry ตอบ failure คนละแบบ: ไม่ใช่ “dependency ค้าง” แต่ “dependency สะดุด” การ deploy Catalog แบบ rolling, pod ถูกย้ายไป node อื่น, network partition สั้น ๆ — พวกนี้โผล่มาเป็น codes.Unavailable และมักหายไปในไม่ถึงวินาที การ fail ทั้ง request ตั้งแต่ครั้งแรกเปราะเกินไปเมื่อ retry ครั้งเดียวอีกไม่กี่ร้อยมิลลิวินาทีต่อมาน่าจะผ่านฉลุย RetryInterceptor ทำแบบนั้นพอดี เมื่อเจอ transient code จะรอ backoff สั้น ๆ แล้วลองใหม่ ไม่เกิน maxAttempts
แต่การ retry มีขอบคม — ปลอดภัยเฉพาะกับ call ที่ idempotent เท่านั้น การ retry GetProduct เสียแค่ read รอบสอง ส่วนการ retry CreateOrder หลังเจอ Unavailable เสี่ยงสร้างคำสั่งซื้อ สอง ใบ ถ้า attempt แรกไปถึง Catalog และ commit สำเร็จจริง แต่ response หายระหว่างทางกลับ interceptor จึง gate ทุก retry ไว้หลังการเช็ค idempotent(method) คือ retry เฉพาะ read (Get*, List*) และไม่เคยแตะ mutation ซึ่งคือความหมายระดับโค้ดที่เป็นรูปธรรมของ “idempotency” ที่ roadmap ระบุเป็นเรื่องของ resilience
รายละเอียดการออกแบบสองอย่างควรพูดให้ชัด เพราะทั้งคู่ทำพลาดแบบละเอียดอ่อนได้ง่าย:
- Backoff ใช้ jitter ไม่ใช่ delay คงที่ ถ้าทุก caller ที่เจอ blip retry หลัง 200ms พอดีเป๊ะ ทุกตัวจะ retry ในวินาทีเดียวกัน แล้วกระหน่ำ dependency ที่กำลังฟื้นเป็น wave พร้อมกัน — “thundering herd” ที่กด service ที่พยายามกลับมาให้ล้มต่อ
jitteredBackoffกระจาย retry ออก: window ที่โตแบบ exponential (base, แล้ว2×base, …) พร้อมส่วน random ข้างใน ดังนั้นไม่มี caller สองตัว retry พร้อมกัน - ลำดับ chain ตัดสินว่า timeout เป็นต่อ attempt หรือ total
DialOptionschainRetryInterceptorไว้ นอกTimeoutInterceptorซึ่งหมายความว่าแต่ละ attempt ได้defaultTimeoutสดของตัวเอง และ retry loop ครอบทั้งหมด chain สลับกัน — timeout นอก retry — deadline เดียวจะครอบทั้ง retry sequence ดังนั้น attempt แรกที่ช้าอาจไม่เหลือเวลาให้ attempt ที่สอง timeout ต่อ attempt พร้อมจำนวน attempt ที่มีขอบเขตคือแบบที่คาดเดาได้กว่า: latency กรณีเลวร้ายสุดคือmaxAttempts × defaultTimeoutและทุก attempt ได้โอกาสสำเร็จจริง
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Interceptor package ที่ client ทุกตัว dial ผ่าน เทียบกับ เขียน timeout และ retry เองที่แต่ละ call site
- Pros: ที่เดียวกำหนด policy และ gRPC client ทุกตัวในระบบ — สอง connection ของ gateway, Catalog client ของ Order, และเซอร์วิสที่เพิ่มทีหลัง — ได้ behavior เหมือนกันและถูกต้องด้วยการเรียก
resilience.DialOptions(); โค้ด business (CreateOrder, gateway handler) ไม่รู้เลยว่า retry หรือ deadline มีอยู่ ตรงตามที่ควรเป็น; เปลี่ยน timeout หรือ backoff เป็นการแก้บรรทัดเดียวในไฟล์เดียว ไม่ต้องไล่หาทุกinvoker - Cons: interceptor มองไม่เห็นจากที่ call site คนอ่าน
CreateOrderจะเห็นแค่ gRPC call ธรรมดา ไม่มีสัญญาณบอกว่าอาจมี retry หรือ timeout พฤติกรรมนี้จึงต้อง รู้มาก่อน ไม่ใช่ อ่านเอาจากโค้ด อีกอย่างคือ policy แบบ global ตัวเดียวค่อนข้างหยาบ เพราะ read ที่เร็วกับ RPC สร้างรายงานที่ช้าจริง ๆ อาจต้องการ deadline คนละค่า ซึ่งDialOptionsแบบ one-size ยังไม่รองรับจนกว่าจะเพิ่ม config ต่อ method
Retry interceptor เขียนเอง (บทนี้) เทียบกับ retry built-in ของ gRPC ผ่าน grpc.WithDefaultServiceConfig
- Pros: เวอร์ชันเขียนเองมองเห็นได้เต็มและ debug ง่าย log ทีละ attempt ได้ และ idempotency gate ก็เป็น Go ธรรมดาที่คุณคุมเอง ไม่ต้องมี service-config JSON แถมทำให้กฎ “retry เฉพาะ method ที่ idempotent” ปรากฏชัดในโค้ด แทนที่จะฝังอยู่ใน policy ต่อ method
- Cons: gRPC มีกลไก retry ที่ mature และผ่านการทดสอบมาแล้ว config ผ่าน service-config JSON ได้ทั้ง retryable code, backoff, max attempt และ hedging ซึ่งระบบ production มักเลือกใช้แทนการเขียนเอง เราเขียน interceptor เองที่นี่เพราะทำให้ทุกชิ้นส่วน สอนได้ ไม่ใช่เพราะดีกว่าของ built-in deployment จริงอาจสลับไปใช้ policy built-in ได้เลยเมื่อเข้าใจแนวคิดแล้ว
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”ก่อนอื่น package:
1. pkg/resilience/interceptor.go
หัวข้อที่มีชื่อว่า “1. pkg/resilience/interceptor.go”// Package resilience hardens this system's synchronous gRPC calls. It// provides grpc.UnaryClientInterceptors — a per-call timeout and a// retry-with-backoff for idempotent methods — and a DialOptions helper// that every gRPC client in the system dials through, so a slow or briefly// unavailable dependency degrades gracefully instead of hanging or failing// on the first blip.package resilience
import ( "context" "log" "math/rand/v2" "strings" "time"
"google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status")
const ( defaultTimeout = 3 * time.Second maxAttempts = 3 baseBackoff = 100 * time.Millisecond)
// DialOptions returns the grpc.DialOptions every service uses to reach// another: insecure transport (local dev), then a chain of client// interceptors. Chain order matters — RetryInterceptor is outermost and// TimeoutInterceptor is innermost, so each individual attempt gets its own// fresh deadline and the retry loop wraps all of them.func DialOptions() []grpc.DialOption { return []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithChainUnaryInterceptor( RetryInterceptor(maxAttempts, baseBackoff), TimeoutInterceptor(defaultTimeout), ), }}
// TimeoutInterceptor bounds every call it wraps with timeout — unless the// caller already set an earlier deadline of their own, which is always// respected. A hung dependency now costs the caller at most timeout before// returning codes.DeadlineExceeded.func TimeoutInterceptor(timeout time.Duration) grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { if _, ok := ctx.Deadline(); !ok { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, timeout) defer cancel() } return invoker(ctx, method, req, reply, cc, opts...) }}
// RetryInterceptor retries a call that fails with a transient code, up to// maxAttempts times, waiting a jittered exponential backoff between tries —// but only for idempotent methods, since retrying a mutation risks// performing it twice. A non-idempotent method, or a non-transient error,// is returned on the first failure.func RetryInterceptor(maxAttempts int, base time.Duration) grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { var err error for attempt := 1; attempt <= maxAttempts; attempt++ { err = invoker(ctx, method, req, reply, cc, opts...) if err == nil || !retryable(err) || !idempotent(method) { return err } if attempt == maxAttempts { break }
backoff := jitteredBackoff(base, attempt) log.Printf("resilience: %s failed (attempt %d/%d), retrying in %s: %v", method, attempt, maxAttempts, backoff, err)
select { case <-ctx.Done(): return status.FromContextError(ctx.Err()).Err() case <-time.After(backoff): } } return err }}
// retryable reports whether err is a transient failure worth retrying — the// dependency was briefly unreachable or too slow, not a permanent rejection.func retryable(err error) bool { switch status.Code(err) { case codes.Unavailable, codes.DeadlineExceeded: return true default: return false }}
// idempotent reports whether the RPC named by fullMethod is safe to call// more than once. This system follows the convention that Get* and List*// RPCs are reads with no side effect; everything else (Create*, and any// future mutation) is treated as unsafe to retry. It's a naming// convention, not a guarantee — a production system would mark idempotency// explicitly per method rather than infer it from a prefix.func idempotent(fullMethod string) bool { name := fullMethod[strings.LastIndex(fullMethod, "/")+1:] return strings.HasPrefix(name, "Get") || strings.HasPrefix(name, "List")}
// jitteredBackoff returns a delay in [window/2, window], where window grows// exponentially with the attempt number (base, 2*base, 4*base, ...). The// random half is "equal jitter": it keeps a sensible minimum wait while// still spreading concurrent callers out, so a recovering dependency isn't// hit by synchronized retry waves.func jitteredBackoff(base time.Duration, attempt int) time.Duration { window := base * time.Duration(1<<(attempt-1)) half := window / 2 return half + time.Duration(rand.Int64N(int64(half)+1))}บันทึกไฟล์นี้เป็น pkg/resilience/interceptor.go
2. Route gRPC client ของ gateway ผ่าน interceptor
หัวข้อที่มีชื่อว่า “2. Route gRPC client ของ gateway ผ่าน interceptor”ใน gateway/cmd/main.go แทนที่ slice dial-options ที่สร้างเอง grpc-gateway →:
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}ด้วยตัวที่ใช้ร่วมกัน:
opts := resilience.DialOptions()เพิ่ม "github.com/avetavos/shopmicro/pkg/resilience" เข้า import แล้วลบ import "google.golang.org/grpc/credentials/insecure" ที่ตอนนี้ไม่ได้ใช้ ทั้ง RegisterCatalogServiceHandlerFromEndpoint และ RegisterOrderServiceHandlerFromEndpoint รับ slice opts นั้นอยู่แล้ว ดังนั้นทั้งสอง gRPC connection ของ gateway ตอนนี้มีขอบเขตและ retry โดยไม่ต้องแก้อะไรเพิ่ม
3. Route Catalog client ของ Order ผ่าน interceptor
หัวข้อที่มีชื่อว่า “3. Route Catalog client ของ Order ผ่าน interceptor”ใน services/order/cmd/main.go แทนที่ dial The gRPC Server →:
catalogConn, err := grpc.NewClient(catalogAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))ด้วย:
catalogConn, err := grpc.NewClient(catalogAddr, resilience.DialOptions()...)เพิ่ม import pkg/resilience และลบ insecure ถ้าไม่ถูกอ้างที่อื่นในไฟล์แล้ว call GetProduct สด ๆ ของ Order เพื่อตั้งราคาแต่ละ line item ตอนนี้ retry อัตโนมัติเมื่อเจอสะดุด และเพราะ GetProduct ขึ้นต้นด้วย Get ทำให้ idempotent return true จึงได้รับอนุญาตให้ retry
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ยก Postgres ขึ้นมาแล้วรัน Catalog, Order, และ gateway:
cd deploy/compose && docker compose up -d postgresgo run ./services/catalog/cmdgo run ./services/order/cmdgo run ./gateway/cmdก่อนอื่น happy path — interceptor โปร่งใสสมบูรณ์เมื่อไม่มีอะไรล้มเหลว สร้าง product แล้ว list กลับมา:
curl -s -X POST localhost:8080/v1/products \ -H 'Content-Type: application/json' \ -d '{"name":"Coffee Mug","description":"350ml ceramic mug","price_cents":1299,"stock":50}'curl -s localhost:8080/v1/productsทั้งคู่ return สิ่งที่เคย return ก่อน module นี้เป๊ะ — call ที่มีขอบเขตและ retry ที่ สำเร็จ หน้าตาเหมือนกับ call ที่ไม่มี interceptor เลย
ทีนี้พิสูจน์ retry หยุด process Catalog ด้วย Ctrl-C ทิ้ง Order และ gateway ให้รันอยู่ แล้ววางคำสั่งซื้อ ซึ่งบังคับให้ Order เรียก Catalog เพื่อตั้งราคา:
curl -s -X POST localhost:8080/v1/orders \ -H 'Content-Type: application/json' \ -d '{"customer_id":"cust-1","items":[{"product_id":"8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21","quantity":2}]}'ใน terminal ของ Order ดู retry interceptor ลอง backoff แล้วลองอีก — call GetProduct idempotent จึงได้รับอนุญาตให้ retry:
resilience: /shopmicro.catalog.v1.CatalogService/GetProduct failed (attempt 1/3), retrying in 78ms: rpc error: code = Unavailable desc = ...resilience: /shopmicro.catalog.v1.CatalogService/GetProduct failed (attempt 2/3), retrying in 143ms: rpc error: code = Unavailable desc = ...หลัง attempt ที่สามล้มเหลว CreateOrder return codes.Unavailable ซึ่ง gateway map เป็น HTTP 503 REST Mapping → — failure ที่สะอาดและเร็ว ไม่ใช่ request ที่ค้าง ทีนี้เริ่ม Catalog อีกครั้ง:
go run ./services/catalog/cmdรันคำสั่งซื้อเดิมอีกครั้งภายในหนึ่งสองวินาที คราวนี้ attempt แรก ๆ ตกตอน Catalog กลับมาแล้ว และคำสั่งซื้อถูกสร้าง — call กู้ตัวเอง ข้ามการ restart ของ dependency ซึ่งคือประเด็นทั้งหมด:
{ "id": "3a7c9e21-1e4d-4b8a-9c6e-2f8b1d5a7c90", "status": "ORDER_STATUS_PENDING", "totalCents": "2598"}สิ่งหนึ่งที่คุณจะ ไม่ เห็น: retry บน write CreateOrder เองไม่ idempotent ดังนั้นถ้าคุณหยุด Order แล้ว POST คำสั่งซื้อ call ของ gateway ไป Order ล้มเหลวตั้งแต่ attempt แรกโดยไม่มีบรรทัด retry เลย — idempotent("/shopmicro.order.v1.OrderService/CreateOrder") เป็น false ตรงตามที่ตั้งใจ เพราะการ retry การ create อาจวางคำสั่งซื้อเดียวกันสองครั้ง
แล้วยืนยันว่า module ยังคง build ผ่าน:
go build ./...ไม่มี output แปลว่าสำเร็จ
ตรวจสอบความเข้าใจ:
DialOptionschainRetryInterceptorไว้นอกTimeoutInterceptorอะไรจะเปลี่ยนเกี่ยวกับ latency กรณีเลวร้ายสุดและ behavior ต่อ attempt ถ้าคุณสลับลำดับกัน?- ทำไม
GetProductถึง retry บนUnavailableแต่CreateOrderไม่? ลาก trace ผลลัพธ์แย่ ๆ ที่การ retryCreateOrderอาจทำให้เกิด TimeoutInterceptorป้องกัน failure อะไรที่RetryInterceptorไม่ช่วยเลย และในทางกลับกัน? ทำไมระบบต้องการทั้งคู่?- ทำไม
jitteredBackoffถึงเพิ่ม randomness แทนที่จะรอ 100ms, 200ms, 400ms คงที่? อะไรพังสำหรับ dependency ที่ กำลังฟื้น ถ้า caller ร้อยตัว retry ตามตารางคงที่เดียวกันหมด?
pkg/resilience/interceptor.go ให้ระบบนี้ grpc.UnaryClientInterceptor สองตัวและ helper DialOptions() ที่ gRPC client ทุกตัว dial ผ่าน TimeoutInterceptor ครอบแต่ละ call ด้วย context.WithTimeout ดังนั้น dependency ที่ค้างคิดอย่างมาก defaultTimeout แทนที่จะ block ตลอดกาล; RetryInterceptor retry Unavailable/DeadlineExceeded แบบ transient ไม่เกิน maxAttempts ด้วย jittered exponential backoff แต่เฉพาะ method Get*/List* ที่ idempotent เท่านั้น ไม่เคยแตะ mutation เพราะการ retry คำสั่ง create อาจทำให้สร้างซ้ำสองครั้ง chain แบบ retry อยู่นอก timeout ทำให้แต่ละ attempt ได้ deadline สดของตัวเอง พร้อมกรณีเลวร้ายสุดที่คาดเดาได้เป็น maxAttempts × defaultTimeout
connection ของ gateway ทั้งสองเส้นและ Catalog client ของ Order ต่างสลับจาก insecure.NewCredentials() เปล่า ๆ มาเป็น resilience.DialOptions() ทุก synchronous hop จึงมีขอบเขตและกู้ตัวเองได้ ขณะที่โค้ด business ไม่ต้องรู้เรื่องอะไรเลย
การ kill Catalog กลาง request พิสูจน์เรื่องนี้ได้ GetProduct ที่ idempotent จะ retry, backoff แล้วล้มเหลวสะอาดเป็น HTTP 503 หรือกู้ตัวเองทันทีที่ Catalog กลับมา ส่วน CreateOrder ที่ไม่ idempotent ปฏิเสธการ retry อย่างถูกต้อง บทถัดไป Circuit breakers → จะเพิ่มชิ้นส่วนที่ retry อย่างเดียวให้ไม่ได้ เพราะเมื่อ dependency down ยาว การ retry ทุก request มีแต่เสียเวลาและเพิ่ม load breaker จึง trip แล้ว fail fast จนกว่า dependency จะพิสูจน์ว่าฟื้นแล้ว