OpenRouter 속도 제한과 HTTP 429 오류: 원인 분석 및 복구 방법
OpenRouter에서 발생하는 HTTP 429 및 402 오류를 진단하고 해결하는 실무 가이드입니다. 무료 티어 일일 쿼터와 플랫폼 레이트 리밋, 업스트림 공급자 한계 간의 차이점을 분석하고, 응답 헤더와 메타데이터를 확인하는 방법 및 복원력을 갖춘 Python 재시도 로직 구현 방안을 제시합니다.
목차

OpenRouter 게이트웨이를 통해 AI 모델로 대량의 요청을 전송할 때, 클라이언트 애플리케이션은 종종 HTTP 429 Too Many Requests 상태 코드를 마주치게 됩니다. OpenRouter는 수십 개의 독립적인 추론 공급자(inference provider)를 하나로 통합하므로, 이 오류는 인프라의 완전히 다른 계층에서 발생할 수 있습니다. 무작정 즉각적인 재시도를 수행하면 클라이언트 단위의 추가 차단으로 이어지거나 아까운 재시도 횟수만 낭비하게 됩니다. 안정적인 요청 흐름을 복구하려면 장애가 발생한 정확한 계층을 식별하고, 지연 대기(backoff), 동시 요청 수 감소, 모델 공급자 전환, 지출 한도(spending cap) 조정 등 상황에 부합하는 해결책을 선택해야 합니다.
제한 계층: 무료 티어, 플랫폼 및 업스트림
공식 OpenRouter Limits 문서에서는 서비스 전반의 속도 제한, 개별 공급자의 처리량(throughput), 계정 잔액에 따른 차단을 명확히 구분합니다.
OpenRouter Pricing 페이지에 명시된 기본 무료 티어의 경우, 공개된 무료 모델(:free 접미사가 붙은 모델)에 대해 하루 50회의 요청 한도가 적용됩니다. 분당 요청 수(RPM) 제한과 티어별 기준값은 변경될 수 있으므로, 실제 수치는 항상 실시간 제한 표에서 확인해야 합니다.
오류를 분석할 때 HTTP 429 및 이와 밀접한 HTTP 402 상태 코드가 발생하는 계층을 다음과 같이 구분하는 것이 매우 중요합니다:
- OpenRouter 플랫폼 속도 제한 (Platform rate limits): 라우터 자체에 요청이 너무 빠르게 유입될 때 발생합니다. 무료 풀 일일 쿼터(daily free pool quota) 역시 별도의 독립적인 제약이 아닌 이 플랫폼 레벨 제한 범주에 속합니다. 무료 모델에서 하루 50건의 제한을 초과하면 일일 카운터가 리셋될 때까지 플랫폼에서 요청을 거부합니다. 플랫폼 레벨의 속도 제한이 발생하면 서버는
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-ResetHTTP 응답 헤더를 반환합니다. 정상적인HTTP 200응답에는 이러한 운영 헤더가 포함되지 않으므로, 일반적인 비오류 트래픽 상황에서 클라이언트 애플리케이션이 이 헤더를 바탕으로 남은 제한을 미리 예측하는 것은 불가능합니다. - 업스트림 공급자 속도 제한 (Upstream rate limit): 모델은 특정 업스트림 기업(Anthropic, Meta, DeepSeek, Mistral 또는 서드파티 전문 호스팅 업체 등)의 인프라에서 물리적으로 호스팅되고 실행됩니다. 해당 파트너의 인프라가 과부하 상태에 도달하면 OpenRouter는 클라이언트에 429 상태 코드를 그대로 전달합니다. 응답 본문 구조에서
error.metadata.provider_code필드는 공급자의 이름이나 문자열 식별자가 아니라, 제공 가능한 경우 업스트림 공급자가 반환한 원시 오류 코드(예: 429)를 담고 있습니다. - 재정적 제한 (
HTTP 402 Payment Required): OpenRouter Limits 문서에서는 잔액 소진과 속도 제한을 명확히 구분합니다. 402 상태 코드는 단순히 잔액이 정확히 0원인 경우만을 뜻하지 않으며, 조직 계정의 잔액이 부족하거나 마이너스 상태이거나, 개별 API 키의 지출 한도(key cap)가 소진되었음을 나타냅니다.
현재 키의 파라미터는 직접적인 API 요청으로 확인할 수 있습니다:
curl -s -X GET https://openrouter.ai/api/v1/key \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
응답 페이로드에는 usage, limit_reset, limit_remaining 필드가 반환됩니다. limit_remaining: null 값은 해당 API 키에 로컬 지출 한도(key cap)가 설정되어 있지 않음을 의미합니다. 이 값은 조직의 기본 잔액에 크레딧이 남아 있는지를 입증하는 것은 아니며, 단지 해당 토큰에 인위적인 한도가 설정되지 않았음을 확인할 뿐입니다.
진단 요약표
| 응답 코드 및 신호 | 확인 위치 | 근본 원인 | 클라이언트 대응 방안 |
|---|---|---|---|
HTTP 402 Payment Required | GET /api/v1/key 엔드포인트 또는 대시보드 | 조직 잔액 부족/마이너스 또는 키 지출 한도 도달 (limit_remaining: 0) | 잔액 충전 또는 키 한도 상향. 변경 없는 프로그래밍 방식 재시도는 무의미함 |
HTTP 429 및 X-RateLimit-* 헤더 | 게이트웨이 HTTP 응답 헤더 | OpenRouter 플랫폼 동시성 또는 요청 빈도 제한 초과 | Retry-After 헤더 확인 및 동시 실행 스레드 수 감소 |
HTTP 429 및 provider_code 코드 포함 | JSON 본문의 error.metadata.provider_code 필드(선택 사항) | 업스트림 공급자 과부하 또는 장애(업스트림의 원시 오류 코드) | 모델 또는 공급자 전환이 도움될 수 있으나 복구를 보장하지는 않음. Activity > provider_responses에서 구체적인 공급자 확인 |
:free 모델에서의 HTTP 429 | OpenRouter Pricing 섹션 | 일일 플랫폼 제한(50회/일) 또는 전체 가용 용량 소진 | 유료 모델로 전환하거나 작업 실행을 연기함 |
복잡한 라우팅 중의 HTTP 429 | 대시보드: Activity > 해당 요청 > View Raw Metadata | provider_responses 객체 내부의 중간 노드 장애 | 실패한 공급자를 식별하고 BYOK/Routing 가이드에서 대체 체인 점검 |
두 가지 시나리오 분석: 키 한도 vs 공급자 장애
요청이 중단되었을 때 클라이언트 애플리케이션이 취해야 할 조치는 메타데이터 분석에 기반합니다. 아래는 실제 계정의 관측 결과가 아닌 설명을 돕기 위한 두 가지 가상 시나리오입니다.
시나리오 1 (가상): 로컬 토큰 한도 소진
이 가상 시나리오에서는 백그라운드 워커가 HTTP 402 거부를 수신합니다. 여기서 조직 잔액이 양수라는 것은 웹 대시보드를 통해 별도로 확인된 명시적인 초기 가정입니다(키 엔드포인트 응답 자체는 조직 전체의 잔액 상태를 증명하지 않습니다). https://openrouter.ai/api/v1/key 엔드포인트에 대한 요청은 다음을 반환합니다:
{
"data": {
"label": "worker-key",
"usage": 25.04,
"limit": 25.0,
"is_free_tier": false,
"limit_remaining": 0.0,
"limit_reset": null
}
}
전제 조건상 기본 계정 잔액은 양수이지만, limit_remaining 필드가 0에 도달했습니다. 해당 토큰이 관리자가 설정한 25달러의 지출 한도에 도달한 것입니다. 이 키를 사용한 모든 추가 재시도는 동일한 402 오류로 반복해서 실패하게 됩니다. 워커 프로세스는 즉시 작업을 중단하고 관리자에게 알림을 보내 키의 한도를 조정하도록 해야 합니다.
시나리오 2 (가상): 업스트림 공급자 과부하
예시로서, 업스트림 장애로 인해 요청이 HTTP 429를 반환하는 경우를 살펴보겠습니다. 429 상태 코드를 받았다는 사실 자체만으로는 게이트웨이의 전반적인 정상 작동 여부나 계정 잔액 유무를 확정할 수 없습니다. 오류 응답 본문에는 다음과 같은 선택적 메타데이터 블록이 포함될 수 있습니다:
{
"error": {
"message": "Provider returned rate limit error",
"code": 429,
"metadata": {
"provider_code": 429
}
}
}
error.metadata.provider_code 필드는 선택 사항이며, 제공되는 경우 업스트림 공급자의 원시 오류 상태 코드(이 예시에서는 429)를 전달할 뿐, 공급자의 이름이나 문자열 식별자를 제공하지는 않습니다. 이 코드의 존재만으로는 구체적으로 어느 호스트가 요청을 거부했는지 알 수 없습니다.
실패한 구체적인 공급자를 식별하려면 관리 콘솔에서 Activity > 특정 요청 > View Raw Metadata 메뉴로 이동해야 합니다. 라우팅 가이드에 설명된 대로, provider_responses 객체에 조회된 각 호스트 목록과 실제 반환 상태가 표시됩니다. 이러한 상황에서 다른 공급자로 전환하거나 모델을 변경하는 것이 도움이 될 수는 있지만, 즉각적인 복구를 보장하는 것은 아닙니다.
Python 3 클라이언트 재시도 스크립트
HTTP 429 상태가 일시적인 경우, 다음 시도까지의 대기 시간은 Retry-After 헤더를 기반으로 계산됩니다. 서버는 이 헤더를 정수 초 단위 또는 HTTP 날짜 형식의 문자열로 전달합니다.
다음 구현은 Python 3 표준 라이브러리만을 사용합니다. 오직 HTTP 429 오류만을 처리하며, 서버에서 지연 힌트를 제공하지 않을 경우 무작위 지터(jitter)가 포함된 지수 백오프를 적용하고, 서버가 60초를 초과하는 대기를 요구하면 실행을 중단합니다.
import email.utils
import json
import os
import random
import sys
import time
import urllib.error
import urllib.request
API_KEY = os.environ.get("OPENROUTER_API_KEY")
MODEL_ID = os.environ.get("OPENROUTER_MODEL_ID", "openai/gpt-4o-mini")
MAX_ATTEMPTS = 3
MAX_ACCEPTABLE_WAIT = 60.0
def parse_retry_after(header_value: str | None) -> float | None:
if not header_value:
return None
raw = header_value.strip()
if raw.isdigit():
return max(0.0, float(raw))
try:
parsed_date = email.utils.parsedate_to_datetime(raw)
delay = parsed_date.timestamp() - time.time()
return max(0.0, delay)
except Exception:
return None
def execute_completion(prompt_text: str) -> str | None:
if not API_KEY:
sys.stderr.write("Переменная окружения OPENROUTER_API_KEY не задана.\n")
return None
endpoint = "https://openrouter.ai/api/v1/chat/completions"
payload = json.dumps(
{
"model": MODEL_ID,
"messages": [{"role": "user", "content": prompt_text}],
}
).encode("utf-8")
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
for attempt in range(1, MAX_ATTEMPTS + 1):
req = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as response:
status_code = response.getcode()
body = response.read().decode("utf-8")
if status_code == 200:
data = json.loads(body)
return data["choices"][0]["message"]["content"]
except urllib.error.HTTPError as err:
if err.code == 429:
retry_header = err.headers.get("Retry-After")
server_delay = parse_retry_after(retry_header)
if server_delay is not None:
wait_seconds = server_delay
else:
base_delay = 2.0 ** attempt
wait_seconds = base_delay + random.uniform(0.1, 1.0)
if wait_seconds > MAX_ACCEPTABLE_WAIT:
sys.stderr.write(
f"Сервер запросил паузу {wait_seconds:.1f} с. "
"Ожидание превышает 60 секунд. Запрос отменен.\n"
)
return None
if attempt == MAX_ATTEMPTS:
sys.stderr.write("Исчерпан лимит из 3 попыток на статус 429.\n")
return None
sys.stderr.write(
f"Получен 429. Попытка {attempt} завершилась неудачей. "
f"Пауза {wait_seconds:.2f} с перед следующим запросом.\n"
)
time.sleep(wait_seconds)
continue
elif err.code == 402:
sys.stderr.write("Ошибка 402: проверьте баланс счета и лимит ключа.\n")
return None
else:
sys.stderr.write(f"HTTP-ошибка {err.code}: запрос отклонен без повтора.\n")
return None
except urllib.error.URLError as err:
sys.stderr.write(f"Сетевой сбой: {err.reason}. Повтор отменен.\n")
return None
return None
if __name__ == "__main__":
result = execute_completion("Назови три базовых принципа надежности сетевых API.")
if result:
print(result)
위 코드 스니펫은 원본의 러시아어 진단 로깅 문자열을 바이트 단위까지 그대로 보존하고 있으므로, 내부 의사결정 트리와 한국어 번역 및 동작 방식은 다음과 같습니다:
Переменная окружения OPENROUTER_API_KEY не задана.(“환경 변수 OPENROUTER_API_KEY가 설정되지 않았습니다.”):OPENROUTER_API_KEY환경 변수가 누락되었음을 나타내며, 네트워크 호출을 시도하지 않고 함수를 즉시 종료합니다.Сервер запросил паузу {wait_seconds:.1f} с. Ожидание превышает 60 секунд. Запрос отменен.(“서버가 {wait_seconds:.1f}초 대기를 요청했습니다. 대기 시간이 60초를 초과하여 요청이 취소되었습니다.”): 서버의Retry-After헤더가MAX_ACCEPTABLE_WAIT(60초)보다 긴 대기를 요구할 때 트리거되며, 워커 프로세스를 무한정 차단하지 않고 실행을 즉시 취소합니다.Исчерпан лимит из 3 попыток на статус 429.(“상태 코드 429에 대한 3회의 재시도 한도가 소진되었습니다.”): HTTP 429 응답에 대해 설정된 최대 3회의 재시도 기회를 모두 소진했음을 의미합니다.Получен 429. Попытка {attempt} завершилась неудачей. Пауза {wait_seconds:.2f} с перед следующим запросом.(“429를 수신했습니다. 시도 {attempt}이(가) 실패했습니다. 다음 요청까지 {wait_seconds:.2f}초 대기합니다.”): 시도 {attempt}에서 429 속도 제한 오류가 발생했음을 기록하고, 계산된 백오프 시간만큼 대기한 후 다음 시도로 넘어갑니다.Ошибка 402: проверьте баланс счета и лимит ключа.(“오류 402: 계정 잔액 및 키 한도를 확인하세요.”): 복구 불가능한 HTTP 402 결제 오류로서 계정 잔액이 부족하거나 키 지출 한도가 소진되었음을 기록하며, 추가 재시도를 진행하지 않습니다.HTTP-ошибка {err.code}: запрос отклонен без повтора.(“HTTP 오류 {err.code}: 재시도 없이 요청이 거부되었습니다.”): 429나 402 이외의 기타 HTTP 오류 상태 코드를 기록하고 재시도 없이 즉시 종료합니다.Сетевой сбой: {err.reason}. Повтор отменен.(“네트워크 오류: {err.reason}. 재시도가 취소되었습니다.”): 소켓이나 일반 네트워크 오류(URLError)를 감지하여, 이전 페이로드가 서버에 전달되어 처리되었는지 불확실한 상태에서 무분별한 재호출을 방지하기 위해 실행을 중단합니다.- 테스트 프롬프트
"Назови три базовых принципа надежности сетевых API."는 *“네트워크 API 신뢰성의 3가지 기본 원칙을 제시해 주세요.”*라는 뜻입니다.
네트워크 요청 재시도와 부작용
자율 에이전트 루프나 툴 콜링(tool calling) 워크플로를 설계할 때 HTTP 요청의 재시도는 극도의 주의를 필요로 합니다. 이전 단계에서 모델이 외부 시스템의 상태를 변경하는 도구(데이터베이스 기록, 결제 처리, 지원 티켓 생성 등)를 이미 호출했다면, 전체 체인을 맹목적으로 재시도할 경우 작업이 중복 실행될 위험이 있습니다. 관련 비즈니스 도구가 이미 실행되었거나, 네트워크 오류 또는 타임아웃처럼 서버가 프롬프트를 실제로 수신하여 처리했는지 여부가 불분명한 모호한 네트워크 결과가 발생했을 때는 절대로 요청을 자동으로 재시도해서는 안 됩니다.
위 스크립트에서 재시도는 명시적으로 HTTP 429 상태 코드로 거부된 요청에 한해서만 엄격하게 실행됩니다. 결정적으로, 텍스트 생성이 본질적으로 멱등성(idempotent)을 가지거나 비용이 들지 않는다고 가정해서는 안 됩니다. 재호출은 토큰 쿼터와 지출 한도를 추가로 소모하며, 모델 샘플링의 확률적 특성상 후속 완성 결과가 이전과 다른 출력을 생성할 수도 있습니다. 에이전트가 외부 명령을 실행하는 도중 장애가 발생했다면, 언어 모델과의 대화를 재개하기 전에 작업 로그를 통해 시스템 상태를 동기화해야 합니다.
복원력 있는 라우팅 아키텍처 구축
요금제 페이지에서 Free 플랜은 무료 모델 전체에 걸쳐 하루 50건의 플랫폼 제한을 적용합니다. 특히 문서에서 언급되는 플랫폼 속도 제한의 부재는 단순히 계정 크레딧을 구매하는 것이 아니라 유료 모델로 전환하는 경우에 적용됩니다. 잔액을 충전한다고 해서 :free 엔드포인트의 제한이 자동으로 해제되는 것은 아닙니다. 계정별 정확한 조건과 변동 가능한 쿼터는 항상 실시간 제한 표에서 확인해야 합니다. 나아가 유료 모델을 사용하더라도 개별 공급자 서버 클러스터의 과부하를 완전히 배제할 수는 없습니다(공급자를 전환하는 것이 도움이 될 수는 있지만 복구를 보장하지는 않습니다).
프로덕션 환경의 안정성을 확보하기 위해 엔지니어링 팀은 일반적으로 다음과 같은 방어 전략을 결합합니다:
- OpenRouter 요청의
models파라미터 배열에 대체 모델(fallback model)을 지정하여, 기본 선택 모델에 오류가 발생할 경우 라우터가 대체 실행자에게 자동으로 트래픽을 전달하도록 구성합니다. - 작업 대기열(job queue), 속도 제한기 또는 토큰 버킷 알고리즘을 사용하여 클라이언트 측에서 최대 동시 요청 수를 제어합니다.
- 미션 크리티컬한 인프라의 경우, 호환 가능한 요청 스키마를 갖춘 다른 멀티 모델 API를 통해 독립적인 백업 라우트를 유지함으로써 주 게이트웨이가 장시간 중단되더라도 트래픽을 원활히 전환할 수 있도록 대비합니다.