Field Log · Entry

LLM Client 복원력: Timeout·Retry·Rate Limit·Circuit Breaker (3/10)

하나의 요청 deadline 안에 제한된 LLM attempt와 full jitter 대기가 배치되고 concurrency limiter와 closed open half-open circuit breaker가 앞단에서 부하를 제어하는 구조

오늘의 결론

  • Timeout은 하나가 아닙니다. 사용자 요청의 전체 deadline, 각 LLM 시도의 attempt timeout, HTTP transport의 connect·read·write·pool timeout을 구분합니다.
  • Retry는 성공 확률을 높이는 동시에 장애 중 부하와 비용을 늘립니다. 재시도 소유자를 한 layer로 정하고 전체 deadline과 attempt budget 안에서만 실행합니다.
  • Retry-After는 중요한 server hint이지만 남은 deadline보다 길면 기다리지 말고 종료합니다. Hint가 없을 때는 capped exponential backoff에 full jitter를 적용합니다.
  • Rate limiter는 들어오는 부하를 제한하고 circuit breaker는 실패 중인 dependency 호출을 빠르게 차단합니다. 둘은 대체 관계가 아닙니다.
  • Streaming에서 첫 delta를 외부로 보낸 뒤에는 같은 요청을 조용히 재시도하지 않습니다. 중복 비용과 중복 text를 감출 수 없기 때문입니다.

앞 글에서는 LLM provider를 typed request·stream event·structured error로 격리했습니다. 이제 retryable=True라는 분류를 실제 실행 policy로 바꾸되, 복원력이 장애 증폭기가 되지 않게 제한합니다.

전체 deadline 안에서 attempt와 jitter가 반복되고 concurrency limiter 및 circuit breaker가 호출 허용 여부를 결정하는 LLM client 복원력 구조

이 글의 대상과 학습 시간

  • 대상: LLM API의 timeout·429·5xx를 처리해야 하는 초·중급 Python backend 개발자
  • 선수 지식: async/await, exception, 82편의 structured error
  • 빠르게 읽기: 약 14분
  • 코드와 fault test까지 따라 하기: 약 60분
  • 산출물: monotonic deadline과 deterministic full-jitter retry loop, circuit breaker 상태 계약

1. “세 번 재시도”가 복원력 설계가 아닌 이유

다음 코드는 흔하지만 위험합니다.

for _ in range(3):
    try:
        return await llm.generate(request)
    except Exception:
        await asyncio.sleep(1)

무엇이 잘못됐는지 분해해 봅니다.

  • 인증 실패와 잘못된 schema도 반복함
  • 각 시도가 얼마 동안 기다릴지 제한하지 않음
  • 100개 요청이 동시에 같은 간격으로 재시도함
  • SDK 내부 retry와 application retry가 곱해질 수 있음
  • 사용자의 전체 latency budget을 넘김
  • 이미 stream한 text를 중복 생성할 수 있음
  • 429가 알려 준 대기 시간을 무시함
  • Dependency가 계속 실패해도 새 호출을 보냄

복원력은 retry 함수 하나가 아니라 시간·시도·동시성·dependency 상태를 하나의 예산으로 조정하는 것입니다.

2. 세 종류의 시간 제한을 분리한다

전체 deadline

사용자 요청이 완료될 수 있는 절대 종료 시점입니다. API→retrieval→LLM→validation 전체가 공유합니다.

request accepted at monotonic 120.0
overall budget 4.0s
absolute deadline 124.0

retrieval used 0.8s
prompt used 0.1s
LLM receives remaining 3.1s, not a fresh 4.0s

Attempt timeout

한 번의 LLM 호출에 허용하는 최대 시간입니다. 첫 시도가 전체 budget을 독점하지 않게 합니다.

Transport timeout

HTTPX는 connect·read·write·pool timeout을 구분합니다. 이는 특정 I/O 단계에서 진행이 없는 시간을 제한하지, 여러 retry와 sleep을 합친 전체 deadline을 대신하지 않습니다.

제한막는 문제예시 소유자
Overall deadline사용자 요청이 끝없이 늘어남API/application
Attempt timeout한 provider call이 예산 독점resilience layer
Connect timeout연결 수립 지연HTTP transport
Read timeout다음 응답 chunk 대기HTTP transport
Write timeoutrequest body 전송 지연HTTP transport
Pool timeoutconnection pool 획득 지연HTTP transport

3. Monotonic deadline을 value object로 만든다

Wall clock은 시간 동기화나 수동 변경으로 앞뒤로 움직일 수 있습니다. 경과 시간 계산은 time.monotonic() 계열을 사용합니다.

from dataclasses import dataclass
from typing import Protocol

class Clock(Protocol):
    def now(self) -> float: ...

@dataclass(frozen=True)
class Deadline:
    expires_at: float

    @classmethod
    def after(cls, seconds: float, *, clock: Clock) -> "Deadline":
        if seconds <= 0:
            raise ValueError("deadline budget must be positive")
        return cls(expires_at=clock.now() + seconds)

    def remaining(self, *, clock: Clock) -> float:
        return max(0.0, self.expires_at - clock.now())

    def require_remaining(self, *, clock: Clock) -> float:
        remaining = self.remaining(clock=clock)
        if remaining <= 0:
            raise DeadlineExceeded("overall_deadline_exceeded")
        return remaining

Deadline은 duration을 하위 함수마다 새로 전달하는 대신 absolute monotonic time으로 전달합니다. 같은 process 안에서는 남은 시간이 자연스럽게 줄어듭니다. Process·host 경계를 넘길 때 monotonic timestamp 자체를 보내면 안 됩니다. RPC에는 남은 duration이나 wall-clock deadline을 보내고 수신 측에서 자신의 monotonic deadline으로 변환합니다.

4. Retry 가능한 오류를 좁게 정의한다

83편의 policy 예시는 다음을 시작점으로 둡니다. Provider 공식 문서와 실제 endpoint의 idempotency 조건에 맞춰 조정해야 합니다.

오류기본 retry이유
Connect reset·일시 DNS 실패조건부요청이 처리됐는지 불확실할 수 있음
Attempt timeout조건부중복 처리·중복 비용 가능성 검토
HTTP 408server가 요청 timeout을 알림
HTTP 429제한 window 이후 성공 가능
HTTP 500·502·503·504일시 dependency 장애 가능
HTTP 400·422아니오같은 입력은 다시 실패
HTTP 401·403아니오credential·권한 수정 필요
Unknown model·invalid schema아니오configuration 또는 code 오류
Citation validation 실패같은 호출 retry 금지모델 반복보다 prompt/evidence 정책 문제일 수 있음

“조건부”의 핵심은 요청이 외부에서 부작용을 만들었는지입니다. LLM generation은 업무 DB 변경은 아니어도 token 비용과 provider-side run을 중복 생성할 수 있습니다. Provider가 idempotency key를 공식 지원한다면 사용하고, 그렇지 않으면 duplicate cost 가능성을 metric에 포함합니다.

5. Retry-After와 full jitter를 결합한다

RFC 9110의 Retry-After는 delay-seconds 또는 HTTP-date 형식일 수 있습니다. 429는 RFC 6585에 정의되어 있으며 server가 이 header로 대기 시간을 알려 줄 수 있습니다.

Hint가 없을 때 full jitter는 다음처럼 계산합니다.

upper(attempt) = min(cap, base × 2^attempt)
sleep = Uniform(0, upper)

모든 client가 base × 2^attempt만큼 정확히 자면 동시에 다시 몰릴 수 있습니다. Jitter는 재시도 시점을 분산합니다.

from dataclasses import dataclass
from random import Random

@dataclass(frozen=True)
class RetryPolicy:
    max_attempts: int = 3
    base_delay: float = 0.2
    max_delay: float = 2.0
    attempt_timeout: float = 2.0

    def __post_init__(self) -> None:
        if self.max_attempts < 1:
            raise ValueError("max_attempts must be at least one")
        if min(self.base_delay, self.max_delay, self.attempt_timeout) <= 0:
            raise ValueError("retry durations must be positive")

def full_jitter_delay(
    retry_index: int,
    *,
    policy: RetryPolicy,
    rng: Random,
) -> float:
    upper = min(policy.max_delay, policy.base_delay * (2**retry_index))
    return rng.uniform(0.0, upper)

retry_index=0은 첫 실패 뒤 첫 대기입니다. Attempt number와 retry index의 기준을 test에서 고정해야 off-by-one을 피할 수 있습니다.

실제 sleep 선택

def choose_delay(
    error: LLMClientError,
    *,
    retry_index: int,
    policy: RetryPolicy,
    rng: Random,
) -> float:
    if error.retry_after_seconds is not None:
        return max(0.0, error.retry_after_seconds)
    return full_jitter_delay(retry_index, policy=policy, rng=rng)

Provider hint가 policy의 일반 cap보다 길 수 있습니다. 이를 억지로 줄여 빨리 호출하기보다, 남은 전체 deadline 안에 들어오지 않으면 이번 사용자 요청은 종료하는 편이 안전합니다.

6. 하나의 retry loop가 모든 예산을 소유한다

SDK 내부 retry를 사용한다면 application loop가 그 횟수와 sleep을 알아야 합니다. 가장 이해하기 쉬운 첫 구현은 SDK 자동 retry를 명시적으로 끄고 이 layer 하나만 retry를 소유하는 것입니다.

import asyncio
from collections.abc import Awaitable, Callable
from typing import TypeVar

T = TypeVar("T")

class Sleeper(Protocol):
    async def sleep(self, seconds: float) -> None: ...

async def call_with_resilience(
    operation: Callable[[float], Awaitable[T]],
    *,
    deadline: Deadline,
    policy: RetryPolicy,
    clock: Clock,
    sleeper: Sleeper,
    rng: Random,
) -> T:
    last_error: LLMClientError | None = None

    for attempt in range(policy.max_attempts):
        remaining = deadline.require_remaining(clock=clock)
        timeout = min(policy.attempt_timeout, remaining)

        try:
            async with asyncio.timeout(timeout):
                return await operation(timeout)
        except asyncio.TimeoutError:
            last_error = LLMClientError(
                code="attempt_timeout",
                retryable=True,
                safe_message="LLM 응답 제한 시간을 초과했습니다.",
            )
        except LLMClientError as exc:
            last_error = exc

        if not last_error.retryable:
            raise last_error
        if attempt + 1 >= policy.max_attempts:
            break

        delay = choose_delay(
            last_error,
            retry_index=attempt,
            policy=policy,
            rng=rng,
        )
        if delay >= deadline.remaining(clock=clock):
            raise DeadlineExceeded("retry_delay_exceeds_deadline")
        await sleeper.sleep(delay)

    assert last_error is not None
    raise RetryExhausted(
        attempts=policy.max_attempts,
        last_error=last_error,
    )

operation(timeout)에 남은 attempt timeout을 전달하면 adapter가 transport timeout도 더 작은 값으로 조정할 수 있습니다. 다만 asyncio.timeout()이 전체 attempt를 감싸므로 adapter가 내부에서 wait를 추가하더라도 상한이 유지됩니다.

Deadline에 아주 조금 남았을 때

남은 20ms로 새 TLS connection과 generation을 시작하는 것은 의미가 없을 수 있습니다. minimum_attempt_budget을 policy에 두고 그보다 적으면 호출 전에 종료할 수 있습니다.

7. Streaming retry는 더 엄격해야 한다

Non-streaming 호출은 실패 후 응답을 아직 사용자에게 공개하지 않았습니다. Streaming은 첫 delta 이후 외부 state가 달라집니다.

before first external delta
  → retry may be allowed within budget

after first external delta
  → do not silently restart
  → emit error terminal
  → mark partial output uncommitted
  → let user explicitly retry with a new run ID

Provider stream이 내부적으로 delta를 만들었지만 API가 아직 client에게 보내지 않은 경우도 있습니다. Retry 경계는 “provider에서 token을 받았는가”와 “외부에 공개했는가”를 별도 flag로 남기는 편이 좋습니다.

8. Rate limit은 요청률과 동시성을 함께 본다

429가 온 뒤 retry만 늦추는 것은 사후 대응입니다. 앞단에서 admission을 제한해야 queue와 비용이 통제됩니다.

동시성 제한

class ConcurrencyLimiter:
    def __init__(self, limit: int) -> None:
        self._semaphore = asyncio.Semaphore(limit)

    async def run(self, operation):
        async with self._semaphore:
            return await operation()

Semaphore는 동시에 실행되는 요청 수를 제한하지만 분당 request/token quota를 직접 표현하지 않습니다.

Token bucket

Token bucket은 일정 속도로 token을 보충하고 burst를 제한합니다. 여기서 token은 LLM token과 이름이 같아 혼동하기 쉬우므로 permit라고 부를 수 있습니다.

request-rate bucket  → calls per interval
token-budget bucket  → estimated input + reserved output tokens
concurrency semaphore → in-flight calls

실제 output token은 호출 전 알 수 없으므로 보수적으로 reserve하고 완료 후 차액을 조정하거나, admission에는 request·input 기준을 쓰고 provider usage로 정책을 재보정합니다.

9. Circuit breaker는 실패한 dependency를 잠시 격리한다

Closed 상태에서 실패 임계치를 넘으면 Open으로 전환하고 recovery timeout 뒤 제한된 Half Open probe만 허용해 성공 시 Closed 실패 시 Open으로 돌아가는 circuit breaker

상태는 세 가지입니다.

상태동작
CLOSED정상 호출, 실패 통계 수집
OPEN호출 없이 빠르게 실패 또는 fallback
HALF_OPEN제한된 probe만 허용해 회복 확인
from enum import StrEnum

class BreakerState(StrEnum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

@dataclass
class CircuitBreaker:
    failure_threshold: int
    recovery_timeout: float
    clock: Clock
    state: BreakerState = BreakerState.CLOSED
    consecutive_failures: int = 0
    opened_at: float | None = None

    def allow(self) -> bool:
        if self.state is BreakerState.CLOSED:
            return True
        if self.state is BreakerState.OPEN:
            assert self.opened_at is not None
            if self.clock.now() - self.opened_at >= self.recovery_timeout:
                self.state = BreakerState.HALF_OPEN
                return True
            return False
        return False  # 첫 구현은 half-open probe 하나만 허용

    def record_success(self) -> None:
        self.state = BreakerState.CLOSED
        self.consecutive_failures = 0
        self.opened_at = None

    def record_failure(self) -> None:
        self.consecutive_failures += 1
        if self.state is BreakerState.HALF_OPEN or (
            self.consecutive_failures >= self.failure_threshold
        ):
            self.state = BreakerState.OPEN
            self.opened_at = self.clock.now()

이 예시는 상태 의미를 보여 주는 단일 process 구현입니다. 여러 worker가 각각 breaker를 가지면 전환 시점이 다릅니다. 처음에는 local breaker로도 충분할 수 있지만 global한 차단이 필요하면 shared control plane과 failure window를 별도로 설계합니다.

어떤 실패를 breaker에 기록하는가

400·401 같은 caller/configuration error를 dependency 장애로 세면 breaker가 잘못 열립니다. Connect failure·timeout·5xx처럼 provider 건강 상태를 나타내는 오류만 포함합니다. 429도 계약된 quota 부족인지 provider 과부하인지에 따라 별도 limiter 신호로 보는 편이 낫습니다.

10. 호출 순서를 명시한다

overall deadline check
  → breaker allow?
  → rate/concurrency permit
  → attempt timeout
  → provider call
  → classify result
  → breaker record
  → retry decision + jitter

Limiter queue에서 기다린 시간도 overall deadline에 포함해야 합니다. Permit을 얻은 뒤 fresh timeout을 주면 과부하 queue가 사용자 latency를 숨깁니다.

11. Fake clock으로 sleep 없는 test를 만든다

class FakeClock:
    def __init__(self) -> None:
        self.value = 0.0

    def now(self) -> float:
        return self.value

class FakeSleeper:
    def __init__(self, clock: FakeClock) -> None:
        self.clock = clock
        self.delays: list[float] = []

    async def sleep(self, seconds: float) -> None:
        self.delays.append(seconds)
        self.clock.value += seconds

def test_full_jitter_stays_inside_cap():
    policy = RetryPolicy(base_delay=0.25, max_delay=1.0)
    rng = Random(7)
    delays = [
        full_jitter_delay(i, policy=policy, rng=rng)
        for i in range(8)
    ]
    assert 0 <= delays[0] <= 0.25
    assert all(0 <= value <= 1.0 for value in delays)

def test_breaker_opens_and_allows_one_probe_after_timeout():
    clock = FakeClock()
    breaker = CircuitBreaker(2, 5.0, clock)

    breaker.record_failure()
    breaker.record_failure()
    assert breaker.state is BreakerState.OPEN
    assert breaker.allow() is False

    clock.value = 5.0
    assert breaker.allow() is True
    assert breaker.state is BreakerState.HALF_OPEN
    assert breaker.allow() is False

Retry loop test에는 scripted operation을 넣습니다.

class ScriptedOperation:
    def __init__(self, outcomes):
        self.outcomes = iter(outcomes)
        self.calls = 0

    async def __call__(self, timeout: float):
        self.calls += 1
        outcome = next(self.outcomes)
        if isinstance(outcome, Exception):
            raise outcome
        return outcome

실제 sleep과 network가 없으므로 429→delay→성공, non-retryable 즉시 종료, deadline 부족, retry exhausted를 빠르게 검증할 수 있습니다.

관측해야 할 metric

  • llm_attempts_per_request
  • llm_retry_total{reason}
  • llm_retry_delay_seconds
  • llm_deadline_exceeded_total{stage}
  • llm_rate_limited_total
  • llm_concurrency_in_flight
  • llm_limiter_queue_seconds
  • llm_breaker_state{provider,model_alias}
  • llm_partial_stream_failure_total
  • llm_duplicate_attempt_risk_total

Provider request ID와 attempt number를 span에 남기되 error body·prompt는 기본 attribute로 저장하지 않습니다.

자주 실패하는 구현 7가지

1. 모든 layer가 retry한다

API gateway 3회 × SDK 2회 × application 3회면 하나의 사용자 요청이 최대 18회 provider attempt가 될 수 있습니다. 소유자와 총 시도 수를 명시합니다.

2. Timeout을 retry마다 새로 시작한다

세 번의 10초 timeout이 30초 요청이 됩니다. Overall deadline은 최초 요청 시 한 번만 만듭니다.

3. Fixed sleep을 쓴다

장애가 풀리는 순간 모든 worker가 동시에 몰립니다. Jitter로 분산합니다.

4. Retry-After를 무조건 기다린다

사용자 deadline을 초과할 수 있습니다. 기다릴 수 없으면 명시적으로 종료합니다.

5. Breaker에 4xx를 포함한다

한 client의 잘못된 요청이 모든 정상 client 호출을 막을 수 있습니다. Dependency health error만 기록합니다.

6. Unbounded queue로 429를 숨긴다

실패가 줄어 보여도 memory와 latency가 폭증합니다. Admission queue에도 상한과 deadline을 둡니다.

7. 첫 delta 뒤 자동 retry한다

사용자는 중복 문장을 보고 provider 비용은 이중으로 발생할 수 있습니다. Partial failure를 terminal로 노출합니다.

직접 적용 체크리스트

  • 최초 요청에서 absolute overall deadline을 한 번 만든다.
  • Attempt·connect·read·write·pool timeout의 역할을 구분한다.
  • Retry owner와 최대 총 attempt를 문서화했다.
  • 4xx configuration error는 retry하지 않는다.
  • Retry-After를 parse하되 남은 deadline을 넘기지 않는다.
  • Hint가 없으면 capped exponential full jitter를 사용한다.
  • Limiter queue 시간도 overall deadline에 포함한다.
  • Rate limiter와 circuit breaker를 다른 신호로 운영한다.
  • 첫 external delta 이후 silent retry를 금지한다.
  • Fake clock·sleeper·RNG로 결정적 fault test를 만든다.

스스로 확인하기

  1. HTTPX read timeout과 사용자 전체 deadline은 어떻게 다른가?
  2. SDK와 application이 동시에 retry할 때 시도 수는 어떻게 계산되는가?
  3. 429의 Retry-After가 남은 deadline보다 길면 무엇을 해야 하는가?
  4. Circuit breaker가 401을 실패로 세면 어떤 잘못된 차단이 생기는가?
  5. Streaming 첫 delta 이후 자동 retry가 어려운 이유는 무엇인가?

자주 묻는 질문

Q1. Retry를 하지 않으면 가용성이 떨어지지 않나요?

일시 오류에는 제한된 retry가 유용합니다. 그러나 deadline·jitter·attempt cap·idempotency 판단 없이 늘리면 dependency 복구를 늦추고 tail latency와 비용을 높일 수 있습니다.

Q2. Circuit breaker library를 쓰면 끝인가요?

상태 기계 구현은 줄일 수 있지만 어떤 error를 세는지, window·threshold·half-open probe 수, fallback과 metric은 application이 결정해야 합니다.

Q3. 429를 받으면 concurrency만 줄이면 되나요?

429는 request rate, token quota, 조직 quota 등 여러 이유일 수 있습니다. Provider header와 usage를 관찰해 request permit·token reservation·concurrency 중 맞는 정책을 조정합니다.

Q4. Timeout 값은 얼마가 정답인가요?

보편적 숫자는 없습니다. 사용자 SLO에서 retrieval·generation·validation budget을 배분하고 실제 latency 분포와 false-timeout 비용을 보며 조정합니다. 글의 숫자는 모두 설명용입니다.

Q5. 여러 provider fallback을 breaker와 연결해도 되나요?

가능하지만 model 품질·context limit·structured output capability가 같다고 가정하면 안 됩니다. Fallback route도 별도 eval과 비용·보안 policy를 통과해야 합니다.

마무리

복원력 layer의 목적은 “끝까지 성공시키기”가 아니라 제한된 자원 안에서 다음을 명확히 결정하는 것입니다.

  1. 지금 새 attempt를 시작할 시간이 있는가?
  2. 이 오류는 같은 요청으로 다시 성공할 가능성이 있는가?
  3. Dependency와 quota가 새 호출을 감당할 수 있는가?
  4. 이미 외부에 partial result를 공개했는가?

다음 글에서는 같은 deadline을 retrieval service까지 전달하고, 빈 검색 결과와 검색 장애를 구분하는 Retrieval Adapter를 구현합니다.

검증 정보

  • 글의 성격: RFC·공식 architecture 문서 해설 + 작성자 설계 제안 + 가상 Python 예시
  • 마지막 검증일: 2026-07-16
  • 검증 환경: Python 3.12 asyncio.timeout, HTTPX timeout 개념 기준
  • 실행 주의: Provider별 retry 대상·idempotency·rate-limit header는 고정한 API version의 공식 문서로 재검증 필요
  • 수치 표기: 4초·3회·0.2초 등은 일반 법칙이 아닌 설명용 시작값
  • 경험 표기: 운영 개선율이나 실제 장애 결과를 주장하지 않음

참고자료