DEV Community

Cover image for AI 에이전트 장애 복구: 재시도, 타임아웃, 백오프, 서킷 브레이커 패턴
Rihpig
Rihpig

Posted on • Originally published at apidog.com

AI 에이전트 장애 복구: 재시도, 타임아웃, 백오프, 서킷 브레이커 패턴

에이전트가 API를 호출합니다. API가 429를 반환합니다. 에이전트는 즉시 재시도하고 또 다른 429를 받습니다. 이 흐름이 반복되면 실행이 중단되거나 비용이 급증할 때까지 스로틀링된 서비스를 계속 호출하는 루프가 만들어집니다. 이는 누군가 의도적으로 작성한 코드라기보다, 순진한 “오류 처리”에서 자주 발생하는 문제입니다. Anthropic SDK 토론 게시판에서도 개발자가 가장 자주 묻는 주제 중 하나입니다.

지금 Apidog 사용해 보기

오류 복구는 에이전트의 데모와 프로덕션 운영을 구분하는 핵심입니다. 문제는 모델 자체가 아니라, 도구 호출이 느려지거나 스로틀링되거나 실패했을 때 애플리케이션이 어떻게 반응하는가입니다.

이 글에서는 다음 네 가지 패턴을 구현 관점에서 다룹니다.

  1. 지수 백오프와 지터를 적용한 재시도
  2. 연결·읽기·전체 실행 타임아웃
  3. 의존성별 서킷 브레이커
  4. 상태 변경 요청을 위한 멱등성 키

마지막에는 모의 환경에서 429, 500, 타임아웃, 응답 유실을 강제로 만들어 복구 경로를 검증하는 방법도 정리합니다. 에이전트 장애의 더 넓은 맥락은 AI 에이전트가 프로덕션에서 고장나는 이유에서 확인할 수 있습니다.

정상적인 API만으로는 복구를 테스트할 수 없습니다

개발 환경에서 의존성은 대부분 정상적으로 동작합니다. 호출은 성공하고, 데모는 깔끔하며, 재시도와 타임아웃 코드는 실행되지 않습니다. 문제는 이 코드가 처음 실행되는 순간이 실제 장애가 발생한 프로덕션일 수 있다는 점입니다.

복구 로직은 실패를 의도적으로 만들어 검증해야 합니다.

  • API가 429 Too Many Requests를 반환하게 만듭니다.
  • Retry-After 헤더를 포함합니다.
  • 연속된 500 Internal Server Error를 반환합니다.
  • 응답을 지연시켜 타임아웃을 유발합니다.
  • 서버는 요청을 처리했지만 응답은 끊기는 상황을 만듭니다.
  • 잘못된 JSON 또는 예상과 다른 응답 본문을 반환합니다.

에이전트가 호출하는 API의 모의 환경을 만들고, 에이전트의 도구 URL을 실제 서비스 대신 모의 URL로 바꾸세요. 그러면 새벽 3시의 실제 장애 대신 테스트에서 복구 경로를 검증할 수 있습니다. Apidog는 모의 환경 설정, 응답 시퀀스 구성, 요청 검증에 사용할 수 있습니다.

지수 백오프와 지터로 재시도 구현하기

즉시 재시도는 일시적인 오류를 해결할 수도 있지만, 부하가 걸린 서비스에는 악영향을 줍니다. 동시에 실패한 클라이언트가 동시에 재시도하면 서비스는 복구할 기회를 얻지 못합니다.

재시도에는 다음 두 가지가 필요합니다.

  • 지수 백오프: 재시도 간격을 점차 늘립니다. 예: 1초, 2초, 4초, 8초
  • 지터(Jitter): 대기 시간에 무작위 값을 더해 재시도 요청이 한 시점에 몰리지 않게 합니다.

Python에서는 다음처럼 구현할 수 있습니다.

import random
import time
import requests

RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}

def request_with_retry(method, url, *, max_attempts=4, base_delay=1.0, max_delay=8.0, **kwargs):
    last_error = None

    for attempt in range(1, max_attempts + 1):
        try:
            response = requests.request(method, url, timeout=(3, 10), **kwargs)

            if response.status_code not in RETRYABLE_STATUS_CODES:
                response.raise_for_status()
                return response

            last_error = RuntimeError(f"retryable status: {response.status_code}")

            if response.status_code == 429 and response.headers.get("Retry-After"):
                delay = float(response.headers["Retry-After"])
            else:
                exponential_delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
                delay = random.uniform(0, exponential_delay)

        except (requests.ConnectionError, requests.Timeout) as error:
            last_error = error
            exponential_delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
            delay = random.uniform(0, exponential_delay)

        if attempt == max_attempts:
            break

        time.sleep(delay)

    raise RuntimeError("request failed after retries") from last_error
Enter fullscreen mode Exit fullscreen mode

구현 시 다음 제한을 반드시 두세요.

  • 최대 재시도 횟수: 일반적으로 3~5회
  • 최대 대기 시간: 요청 하나가 몇 분씩 대기하지 않도록 제한
  • 재시도 대상: 모든 4xx를 재시도하지 말고, 429, 500, 502, 503, 504, 연결 오류, 타임아웃처럼 일시적일 가능성이 높은 오류만 선택

Anthropic SDK는 자체 호출에서 일부 재시도와 지수 백오프를 처리합니다. 하지만 에이전트 도구가 호출하는 결제, 검색, CRM, 이메일 API까지 자동으로 처리하지는 않습니다. 도구 API는 별도의 래퍼로 보호해야 합니다.

금전적 작업에서는 재시도 정책이 특히 중요합니다. 고위험 API를 위한 재시도 로직은 부주의한 재시도가 실제 손실로 이어지는 사례를 다룹니다.

모든 외부 호출에 타임아웃 설정하기

재시도는 요청이 실패했다는 신호가 있어야 동작합니다. 더 위험한 경우는 서버가 연결을 수락한 뒤 응답을 보내지 않는 상황입니다.

타임아웃이 없으면 도구 호출 하나가 전체 에이전트 실행을 멈출 수 있습니다. 오류도 발생하지 않고, 재시도도 하지 않으며, 사용자와 토큰 예산만 기다리게 됩니다.

외부 호출에는 최소 세 종류의 제한이 필요합니다.

제한 목적
연결 타임아웃 TCP/TLS 연결 수립에 허용할 시간
읽기 타임아웃 연결 후 응답 본문을 기다리는 시간
전체 실행 예산 여러 도구 호출과 재시도를 포함한 에이전트 전체 시간

예를 들어 requests에서는 연결·읽기 타임아웃을 분리할 수 있습니다.

response = requests.get(
    "https://api.example.com/search",
    timeout=(3, 10),  # 연결 3초, 읽기 10초
)
Enter fullscreen mode Exit fullscreen mode

타임아웃 값은 추측으로 정하지 마세요. 의존성의 실제 지연 시간, 특히 p99를 기준으로 정하고 여유를 조금 추가하세요.

  • 너무 짧으면 정상 요청까지 중단됩니다.
  • 너무 길면 멈춘 의존성이 사용자 경험을 망칩니다.
  • 스트리밍 응답은 일반 요청과 다른 시간 예산을 적용해야 합니다.

타임아웃도 재시도 가능한 오류로 분류할 수 있지만, 최대 시도 횟수와 전체 실행 예산을 함께 적용해야 합니다.

의존성이 다운되면 서킷 브레이커 열기

백오프는 일시적으로 혼잡한 서비스를 위한 전략입니다. 서비스가 완전히 다운된 상태라면 반복 재시도는 사용자 대기 시간과 장애 서비스의 부하만 늘립니다.

서킷 브레이커는 일반적으로 세 상태를 가집니다.

  1. 닫힘(Closed): 정상적으로 요청을 전달하며 실패 횟수를 기록합니다.
  2. 열림(Open): 실패 임계값을 넘으면 요청을 즉시 거절합니다.
  3. 반개방(Half-Open): 쿨다운 후 제한된 수의 프로브 요청을 허용합니다.

흐름은 다음과 같습니다.

Closed
  └─ 실패 임계값 초과 → Open
Open
  └─ 쿨다운 만료 → Half-Open
Half-Open
  ├─ 프로브 성공 → Closed
  └─ 프로브 실패 → Open
Enter fullscreen mode Exit fullscreen mode

에이전트에서 중요한 원칙은 의존성별로 브레이커를 분리하는 것입니다.

  • 검색 API가 다운되었다고 청구 API까지 막으면 안 됩니다.
  • 결제 API가 열려 있어도 읽기 전용 캐시 API는 계속 동작할 수 있습니다.
  • 브레이커가 열렸을 때는 타임아웃까지 기다리지 말고 빠르게 실패를 반환해야 합니다.

예를 들어 도구 계층에서 다음처럼 실패를 사용자 또는 상위 오케스트레이터가 처리할 수 있는 형태로 반환할 수 있습니다.

{
    "ok": False,
    "error": "DEPENDENCY_UNAVAILABLE",
    "service": "payment-api",
    "retry_after_seconds": 30
}
Enter fullscreen mode Exit fullscreen mode

이렇게 하면 에이전트는 동일한 도구를 계속 호출하는 대신, 대체 경로를 선택하거나 사용자에게 명확한 상태를 안내할 수 있습니다.

멱등성 키로 상태 변경 재시도를 안전하게 만들기

재시도는 읽기 요청에는 비교적 안전하지만, 상태를 변경하는 요청에서는 위험할 수 있습니다.

예를 들어 다음 상황을 생각해 봅시다.

  1. 에이전트가 POST /charge를 전송합니다.
  2. 서버는 실제로 결제를 처리합니다.
  3. 네트워크 오류로 응답이 클라이언트에 도착하지 않습니다.
  4. 클라이언트는 실패했다고 판단하고 재시도합니다.
  5. 고객이 두 번 청구됩니다.

이 문제는 멱등성 키로 해결합니다. 논리적 작업 하나에 대해 키를 한 번 생성하고, 모든 재시도에서 같은 키를 전송하세요.

import uuid
import requests

idempotency_key = str(uuid.uuid4())

response = request_with_retry(
    "POST",
    "https://api.example.com/charge",
    json={
        "customer_id": "cus_123",
        "amount": 5000,
        "currency": "KRW",
    },
    headers={
        "Idempotency-Key": idempotency_key,
    },
)
Enter fullscreen mode Exit fullscreen mode

핵심은 키 생성 위치입니다.

# 잘못된 예: 재시도마다 키가 달라짐
for attempt in range(3):
    headers = {"Idempotency-Key": str(uuid.uuid4())}
Enter fullscreen mode Exit fullscreen mode
# 올바른 예: 논리적 작업마다 한 번 생성
idempotency_key = str(uuid.uuid4())

for attempt in range(3):
    headers = {"Idempotency-Key": idempotency_key}
Enter fullscreen mode Exit fullscreen mode

멱등성 키가 필요한 요청은 다음과 같습니다.

  • 결제 및 환불
  • 주문 생성
  • 이메일·메시지 발송
  • 레코드 생성
  • 상태 변경
  • 외부 시스템에 작업을 제출하는 API

서버는 키를 저장하고, 같은 키가 다시 오면 작업을 다시 수행하는 대신 최초 결과를 반환해야 합니다. 자세한 구현 방식은 멱등성 키 가이드에서 확인할 수 있습니다.

속도 제한과 RateLimitError 루프 피하기

429 응답은 서버가 “잠시 기다린 뒤 다시 시도하라”고 알려주는 신호입니다. 많은 API는 Retry-After 헤더를 함께 제공합니다.

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

이 경우 클라이언트는 최소 30초를 기다려야 합니다. 2초 후 재시도하면 또 다른 429를 받고, 더 강한 제한이나 긴 차단으로 이어질 수 있습니다.

Retry-After를 우선 처리하세요.

def get_retry_delay(response, attempt, base_delay=1.0, max_delay=8.0):
    retry_after = response.headers.get("Retry-After")

    if retry_after:
        try:
            return float(retry_after)
        except ValueError:
            # HTTP-date 형식은 별도 파싱 처리 가능
            pass

    exponential_delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
    return random.uniform(0, exponential_delay)
Enter fullscreen mode Exit fullscreen mode

정리하면 429 처리 규칙은 다음과 같습니다.

  1. Retry-After가 있으면 해당 값을 우선 적용합니다.
  2. 헤더가 없으면 지수 백오프와 지터로 폴백합니다.
  3. 최대 시도 횟수를 넘으면 깔끔하게 실패합니다.
  4. 무한 재시도하지 않습니다.

속도 제한 초과 응답은 보통 이 정보를 제공합니다. 별도의 SDK 스레드에서도 너무 빠른 재시도로 발생하는 동일한 문제가 논의됩니다.

사후 복구 외에도 사전 페이싱이 필요합니다. 제공업체가 분당 요청 수 제한을 공개한다면, 토큰 버킷 같은 클라이언트 측 제한기를 두어 429에 도달하기 전에 요청 속도를 제어하세요.

복구: 제한에 도달한 뒤 어떻게 처리할 것인가
페이싱: 제한에 도달하지 않도록 어떻게 요청 속도를 제어할 것인가
Enter fullscreen mode Exit fullscreen mode

복구 경로를 테스트하는 방법

복구 패턴은 코드에 존재하는 것만으로 충분하지 않습니다. 실제로 실행되었고, 기대한 동작을 했다는 증명이 필요합니다.

다음 시나리오를 모의 환경에서 구성하세요.

1. 의존성 모의

에이전트 도구가 호출하는 API의 모의 환경을 만듭니다. 다음 요소를 제어할 수 있어야 합니다.

  • 상태 코드
  • 응답 헤더
  • 응답 본문
  • 응답 지연 시간
  • 호출 순서
  • 수신한 요청 헤더와 본문

이 환경에서는 실제 결제, 이메일 발송, 주문 생성이 일어나지 않아야 합니다.

2. 복구 시퀀스 구성

하나의 엔드포인트에 다음 응답 시퀀스를 설정합니다.

1번째 호출 → 429 + Retry-After: 2
2번째 호출 → 500
3번째 호출 → 200 + 정상 JSON 본문
Enter fullscreen mode Exit fullscreen mode

이 단일 시나리오로 다음을 확인할 수 있습니다.

  • 429 이후 최소 2초를 기다렸는가
  • 500을 재시도 가능한 오류로 처리했는가
  • 세 번째 요청에서 정상 응답을 반환했는가
  • 최대 시도 횟수를 초과하지 않았는가

3. 포기 경로 테스트

계속 실패하는 의존성도 테스트해야 합니다.

1번째 호출 → 503
2번째 호출 → 503
3번째 호출 → 503
4번째 호출 → 503
Enter fullscreen mode Exit fullscreen mode

이 경우 단언할 항목은 다음과 같습니다.

  • 요청이 무한 루프에 빠지지 않았는가
  • 설정한 최대 횟수까지만 시도했는가
  • 호출자에게 일관된 오류를 반환했는가
  • 로그와 메트릭에 실패 원인이 남았는가

4. 서킷 브레이커 테스트

연속 실패를 발생시킨 뒤 브레이커가 열리는지 확인하세요.

연속 실패 → 브레이커 Open → 다음 요청은 즉시 실패 → 쿨다운 → Half-Open 프로브
Enter fullscreen mode Exit fullscreen mode

특히 Open 상태에서 실제 HTTP 요청이 전송되지 않는지 확인해야 합니다. 그래야 모든 요청이 타임아웃될 때까지 기다리는 비용을 피할 수 있습니다.

5. 멱등성 테스트

멱등성은 반드시 응답 유실 상황으로 검증해야 합니다.

  1. 모의 서버가 상태 변경 요청을 정상 처리합니다.
  2. 서버가 응답을 끊거나 드롭합니다.
  3. 에이전트는 실패로 판단하고 재시도합니다.
  4. 모의 서버가 두 번째 요청을 수신합니다.

이때 다음을 단언하세요.

- 두 요청의 Idempotency-Key가 같은가?
- 서버가 논리적 작업을 한 번만 처리했는가?
- 두 번째 요청은 최초 결과를 반환했는가?
Enter fullscreen mode Exit fullscreen mode

새 키가 생성되거나 동일한 작업이 두 번 처리된다면, 실제 고객이 발견하기 전에 테스트에서 이중 전송 문제를 찾은 것입니다. 더 넓은 테스트 방법은 API를 호출하는 에이전트 테스트에서 확인할 수 있습니다.

오류 복구 체크리스트

프로덕션 배포 전 다음 항목을 확인하세요.

  • [ ] 모든 외부 호출에 연결 타임아웃과 읽기 타임아웃이 있다.
  • [ ] 에이전트 전체 실행에 총 시간 예산이 있다.
  • [ ] 재시도는 지수 백오프와 지터를 사용한다.
  • [ ] 재시도 횟수와 최대 지연 시간이 제한되어 있다.
  • [ ] 429 응답에서 Retry-After를 읽고 준수한다.
  • [ ] Retry-After가 없을 때만 백오프를 폴백으로 사용한다.
  • [ ] 서킷 브레이커가 전역이 아닌 의존성별로 동작한다.
  • [ ] 상태 변경 요청에는 안정적인 멱등성 키가 있다.
  • [ ] 재시도 중에도 같은 논리적 작업의 키가 유지된다.
  • [ ] 포기 경로가 무한 대기 대신 명확한 오류를 반환한다.
  • [ ] 429, 500, 타임아웃, 응답 유실 시나리오를 모의 테스트로 검증했다.

이 항목을 모두 충족하면 에이전트는 운 좋게 복구하는 것이 아니라, 설계대로 복구합니다.

Apidog가 적합한 지점과 그렇지 않은 지점

도구의 역할을 명확히 구분해야 합니다. Apidog는 에이전트 프레임워크, 모델 호스트, 런타임, 오케스트레이터가 아닙니다. 에이전트를 구축하거나 실행하지 않으며 모델 출력을 평가하지도 않습니다.

Apidog가 담당하는 영역은 에이전트가 호출하는 API 계층입니다. 즉, 복구 정책이 실제로 검증되어야 하는 지점입니다.

실무에서는 다음 용도로 활용할 수 있습니다.

  1. 의존성 모의: 실제 서비스 대신 제어 가능한 API 환경을 제공합니다.
  2. 실패 응답 구성: 429, Retry-After, 500, 타임아웃, 잘못된 본문을 시나리오별로 만듭니다.
  3. 요청 검증: 멱등성 키, 요청 형식, 호출 횟수, 헤더 존재 여부를 검증합니다.

즉, Apidog는 에이전트가 견뎌야 하는 실패를 모의하고, 실패 이후 에이전트가 어떤 요청을 다시 보내는지 검증하는 데 적합합니다.

자주 묻는 질문

Anthropic SDK가 재시도를 자동으로 처리해주지 않나요?

자체 호출에 대해서는 일부 처리합니다. 특정 오류를 지수 백오프로 재시도하고 Retry-After를 준수하며, max-retries 옵션으로 상한을 설정할 수 있습니다. 하지만 에이전트 도구가 호출하는 다른 API까지 처리하지는 않습니다. 결제, 검색, CRM 등 외부 도구 API에는 같은 패턴을 직접 적용해야 합니다.

멱등성 키는 언제 필요한가요?

상태를 생성하거나 변경하는 모든 호출에 필요합니다. 결제, 주문 생성, 메시지 발송, 새 레코드 생성이 대표적입니다. 읽기 전용 호출은 일반적으로 멱등성 키 없이 재시도할 수 있습니다. 단, 상태 변경 작업은 논리적 작업당 한 번만 키를 만들고 모든 재시도에서 재사용해야 합니다.

이번 주에는 실패 하나만 연습하세요

네 가지 패턴을 한 번에 모두 구현할 필요는 없습니다. 가장 큰 피해를 만들 수 있는 경로 하나부터 시작하세요. 보통은 속도 제한 루프 또는 멱등하지 않은 재시도입니다.

다음 시나리오를 먼저 만들어 보세요.

429 + Retry-After 반환
→ 에이전트가 지정된 시간만큼 대기
→ 재시도
→ 정상 응답 반환
Enter fullscreen mode Exit fullscreen mode

그다음 상태 변경 요청에서 응답을 유실시키세요.

서버는 요청 처리
→ 응답 유실
→ 에이전트 재시도
→ 동일한 Idempotency-Key 검증
→ 작업은 한 번만 처리
Enter fullscreen mode Exit fullscreen mode

이중 청구나 중복 이메일 발송이 발생할 수 있는 지점에서, 깔끔한 백오프와 안정적인 멱등성 키를 확인할 수 있다면 성공적인 데모보다 더 신뢰할 수 있는 에이전트를 만들 수 있습니다.

Top comments (0)