본문 바로가기

프로그래밍/golang

[Golang] 에러 분류를 통한 Retry 구현

 

시스템 개발 및 운영을 하다보면 외부 API 호출이나 DB 작업 중 여러 에러들을 마주하게 됩니다.
발생하는 에러들을 파악하고, 재시도를 해야하는 에러인지 아닌지를 판단하고, 다시 재시도하는 로직을 함께 구현해보겠습니다.

 

1. 에러 재시도

모든 에러는 각 성격에 맞춰 명확히 구분하고, 그 구분된 기준으로 에러를 재시도할지 말지를 판단하는 것이 중요합니다.

  • 재시도 : 503(Service Unavailable), 429(Too Many Requests),
    데이터베이스 연결 끊김 등과 같이 일시적인 네트워크 오류같이 재시도 할 경우 성공할 수 있는 경우.
  • 재시도 불가 : 400(Bad Request), 401(Unauthorized) 등 같은 조건에서 시도해도 동일한 결과가 나올 경우

 

2. 예제 코드

 

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

var ErrRateLimited = errors.New("rate limited")

type UpstreamError struct {
	Code int
	Err  error
}

func (e *UpstreamError) Error() string { return fmt.Sprintf("upstream code=%d: %v", e.Code, e.Err) }
func (e *UpstreamError) Unwrap() error { return e.Err }

func IsRetryable(err error) bool {
	if err == nil {
		return false
	}

	// context error
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}

	// sentinel error
	if errors.Is(err, ErrRateLimited) {
		return true
	}

	// typed error
	var ue *UpstreamError
	if errors.As(err, &ue) {
		return ue.Code >= 500 && ue.Code <= 504
	}

	return false
}

func CallWithRetry(ctx context.Context) error {
	maxRetries := 3
	backoff := 100 * time.Millisecond // default waiting time

	for i := 0; i < maxRetries; i++ {
		err := callUpstream(ctx)
		if err == nil {
			return nil // 성공 시 즉시 반환
		}

		// 재시도 가능한 에러인지 확인
		if !IsRetryable(err) {
			return fmt.Errorf("non-retryable error: %w", err)
		}

		fmt.Printf("retry... (%d/%d): %v\n", i+1, maxRetries, err)

		// 다음 시도 전 대기
		select {
		case <-time.After(backoff):
			backoff *= 2 // 대기 시간을 늘려 서버 부담을 줄임
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	return errors.New("max retries exceeded")
}

// error func
func callUpstream(ctx context.Context) error {
	return &UpstreamError{Code: 503, Err: errors.New("service unavailable")}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := CallWithRetry(ctx); err != nil {
		fmt.Printf("result error : %v \n", err)
	}
}

 

 

3. 핵심 정리

  • Context
    • context 관련 에러체크를 우선 진행합니다.
      클라이언트가 요청을 중지하거나 주어진 시간을 초과했다면 더 이상 수행할 이유가 없음으로 우선적으로 확인합니다.
  • Backoff
    • 실패 직후 바로 재시도를 하기보다는 backoff을 줌으로써 대기시간을 점차 증가시켜 줍니다.
      이는 리소스 낭비 방지 뿐만 아니라 상대 서버가 리커버리 될 수 있는 시간을 줍니다

 

4. 전체 코드

https://github.com/reochoi109/go-handbook/blob/main/error/advanced/retryable/main.go

 

go-handbook/error/advanced/retryable/main.go at main · reochoi109/go-handbook

A personal handbook of Go patterns and best practices. Lightweight, practical code snippets for real-world backend development. - reochoi109/go-handbook

github.com