Field Log · Entry
LLM Client 복원력: Timeout·Retry·Rate Limit·Circuit Breaker (3/10)
오늘의 결론
- 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로 바꾸되, 복원력이 장애 증폭기가 되지 않게 제한합니다.
이 글의 대상과 학습 시간
- 대상: 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 timeout | request body 전송 지연 | HTTP transport |
| Pool timeout | connection 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 408 | 예 | server가 요청 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 | 호출 없이 빠르게 실패 또는 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_requestllm_retry_total{reason}llm_retry_delay_secondsllm_deadline_exceeded_total{stage}llm_rate_limited_totalllm_concurrency_in_flightllm_limiter_queue_secondsllm_breaker_state{provider,model_alias}llm_partial_stream_failure_totalllm_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를 만든다.
스스로 확인하기
- HTTPX read timeout과 사용자 전체 deadline은 어떻게 다른가?
- SDK와 application이 동시에 retry할 때 시도 수는 어떻게 계산되는가?
- 429의 Retry-After가 남은 deadline보다 길면 무엇을 해야 하는가?
- Circuit breaker가 401을 실패로 세면 어떤 잘못된 차단이 생기는가?
- 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의 목적은 “끝까지 성공시키기”가 아니라 제한된 자원 안에서 다음을 명확히 결정하는 것입니다.
- 지금 새 attempt를 시작할 시간이 있는가?
- 이 오류는 같은 요청으로 다시 성공할 가능성이 있는가?
- Dependency와 quota가 새 호출을 감당할 수 있는가?
- 이미 외부에 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초 등은 일반 법칙이 아닌 설명용 시작값
- 경험 표기: 운영 개선율이나 실제 장애 결과를 주장하지 않음