파이썬 requests로 API 에러 처리하기 — 타임아웃·재시도·429

🌐 English

파이썬으로 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의 문서와 이용 제한 정책을 확인하시기 바랍니다.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기