파이썬 requests로 API 에러 처리하기 — 타임아웃·재시도·429
파이썬으로 API를 부르는 코드는 처음엔 한 줄입니다.
data = requests.get(url).json()
로컬에서는 잘 됩니다. 그런데 이 코드를 서버에 올려 몇 시간 돌리면 반드시 터집니다. 응답이 안 와서 영원히 멈춰 있거나, 429가 돌아와서 .json()이 예외를 뿜거나, 토큰이 만료돼 401이 나거나요.
이 글은 그 한 줄을 새벽에 안 깨우는 코드로 만드는 과정입니다.
1. 타임아웃 — 가장 흔하고 가장 치명적인 누락
requests는 기본 타임아웃이 없습니다. 서버가 응답을 안 주면 프로그램이 무한정 기다립니다. 몇 시간 뒤에 봐도 그대로 멈춰 있습니다. 로그도 안 남습니다. 그냥 조용히 정지합니다.
# 나쁨 — 서버가 안 죽고 안 답하면 영원히 대기
res = requests.get(url)
# 좋음 — (연결 타임아웃, 읽기 타임아웃)
res = requests.get(url, timeout=(3, 10))
튜플로 주면 연결에 3초, 응답 본문 수신에 10초라는 뜻입니다. 연결은 빨리 포기하고 데이터 수신은 조금 여유를 주는 게 보통 좋습니다. 값이 애매하면 timeout=10 하나만 줘도 아무것도 안 주는 것보다 훨씬 낫습니다.
2. 어떤 에러가 나는지 구분한다
모든 실패를 except Exception으로 묶어 버리면 재시도해야 할 것과 하면 안 되는 것을 구분할 수 없습니다. requests의 예외는 크게 이렇게 나뉩니다.
Timeout— 시간 초과. 재시도할 가치가 있음ConnectionError— 연결 실패, DNS 오류, 네트워크 끊김. 재시도 가치 있음HTTPError— 4xx/5xx 응답. 상태 코드에 따라 다름
상태 코드별 판단 기준은 이렇습니다.
- 5xx: 서버 문제. 재시도합니다.
- 429: 너무 자주 불렀음. 기다렸다가 재시도합니다.
- 401 / 403: 인증 문제. 그냥 재시도하면 똑같이 실패합니다. 토큰을 갱신하고 다시 시도해야 합니다.
- 400 / 422: 요청이 잘못됐습니다. 재시도는 의미 없습니다. 100번 보내도 100번 틀립니다. 로그 남기고 즉시 포기하세요.
3. 지수 백오프로 재시도하기
재시도할 때 매번 0.5초씩 쉬면, 서버가 힘들어서 죽어 있는 상황에 요청을 더 퍼붓는 꼴이 됩니다. 실패할수록 대기 시간을 늘리는 방식(지수 백오프)이 정석입니다.
import time, random, requests
RETRIABLE_STATUS = {429, 500, 502, 503, 504}
def request_with_retry(method, url, max_retries=3, **kwargs):
kwargs.setdefault("timeout", (3, 10))
last_err = None
for attempt in range(max_retries):
try:
res = requests.request(method, url, **kwargs)
# 재시도할 가치가 없는 실패는 바로 반환
if res.status_code < 400:
return res
if res.status_code not in RETRIABLE_STATUS:
res.raise_for_status()
last_err = f"HTTP {res.status_code}"
wait = backoff_seconds(attempt, res)
except (requests.Timeout, requests.ConnectionError) as e:
last_err = str(e)
wait = backoff_seconds(attempt, None)
if attempt < max_retries - 1:
time.sleep(wait)
raise RuntimeError(f"{max_retries}회 재시도 실패: {last_err}")
def backoff_seconds(attempt, res):
# 1초 → 2초 → 4초, 여기에 무작위 지터를 더한다
base = 2 ** attempt
return base + random.uniform(0, 0.5)
지터(jitter), 즉 무작위 값을 더하는 이유가 있습니다. 봇 여러 대가 동시에 실패하면 전부 정확히 같은 시각에 재시도합니다. 그러면 서버는 다시 한꺼번에 얻어맞습니다. 0~0.5초의 무작위 값만 섞어도 이 동기화가 풀립니다.
4. 429는 Retry-After를 읽어라
429(Too Many Requests)를 받으면 많은 서버가 언제 다시 오라고 알려줍니다. 응답 헤더의 Retry-After입니다. 이걸 무시하고 내 마음대로 재시도하면 차단이 길어질 수 있습니다.
def backoff_seconds(attempt, res):
if res is not None and res.status_code == 429:
retry_after = res.headers.get("Retry-After")
if retry_after:
try:
return float(retry_after) + 0.1 # 서버가 시킨 만큼 기다린다
except ValueError:
pass # 날짜 형식이면 아래 기본값 사용
return max(5.0, 2 ** attempt) # 헤더 없으면 넉넉히
return 2 ** attempt + random.uniform(0, 0.5)
더 나은 방법은 429를 아예 안 맞는 것입니다. 호출 간 최소 간격을 강제하는 장치를 클라이언트 쪽에 두면 됩니다.
class RateLimiter:
"""호출 사이에 최소 간격을 보장한다"""
def __init__(self, min_interval=0.1):
self.min_interval = min_interval
self.last_call = 0.0
def wait(self):
elapsed = time.time() - self.last_call
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self.last_call = time.time()
5. 401은 재시도가 아니라 토큰 갱신이다
토큰 기반 API에서 가장 자주 만나는 실패입니다. 토큰은 대개 만료 시각이 있고, 지나면 401이 옵니다. 이때 필요한 건 대기가 아니라 재발급입니다.
def call_api(path, **kwargs):
headers = kwargs.pop("headers", {})
headers["Authorization"] = get_token()
res = request_with_retry("GET", BASE + path, headers=headers, **kwargs)
if res.status_code == 401:
refresh_token() # 토큰 새로 받고
headers["Authorization"] = get_token()
res = request_with_retry("GET", BASE + path, headers=headers, **kwargs)
res.raise_for_status()
return res.json()
여기서 재발급 시도는 딱 한 번만 해야 합니다. 401이 계속 나는데 무한히 갱신을 시도하면, 키 자체가 잘못된 경우 무한 루프에 빠집니다.
6. .json()도 실패한다
마지막 함정입니다. 상태 코드가 200이어도 본문이 JSON이 아닐 수 있습니다. 서버 앞단의 프록시가 HTML 에러 페이지를 돌려주는 경우가 대표적입니다.
try:
data = res.json()
except ValueError:
logging.error("JSON 파싱 실패: %s", res.text[:200]) # 앞부분만 남긴다
raise
res.text를 통째로 로그에 남기면 응답이 클 때 로그 파일이 순식간에 부풀어 오릅니다. 앞 200자만으로도 원인 파악에는 충분합니다.
체크리스트
API 호출 코드를 커밋하기 전에 이것만 확인하면 사고의 대부분을 막습니다.
- 모든 요청에
timeout이 있는가 - 재시도해도 소용없는 4xx에 재시도를 걸고 있지 않은가
- 대기 시간이 실패할수록 늘어나는가, 지터가 있는가
- 429에서
Retry-After를 읽는가 - 401에서 토큰을 갱신하되 한 번만 하는가
- 최종 실패했을 때 무엇이 왜 실패했는지 로그에 남는가
마지막 항목이 의외로 중요합니다. 재시도 코드를 잘 만들어 놓고 except: pass로 조용히 삼켜 버리면, 봇은 안 죽지만 아무 일도 안 하는 상태가 됩니다. 실패는 반드시 소리를 내야 합니다.
이 글은 교육·정보 목적으로 작성했습니다. 코드는 예시이며, 실제 서비스에 적용할 때는 대상 API의 문서와 이용 제한 정책을 확인하시기 바랍니다.
댓글
댓글 쓰기