API 호출 실패, 언제 재시도하고 언제 포기할까

외부 API 호출 실패 시 무조건 재시도하면 오히려 상황이 나빠질 수 있습니다. 실패 원인을 먼저 구분해 재시도할지 포기할지 정하는 정책을 Python과 httpx 예시로 정리합니다.

실패했다고 다시 부르면, 더 나빠집니다

외부 API를 호출하는 코드를 짜다 보면 자연스럽게 이런 생각이 듭니다. “실패하면 몇 번 더 해보면 되겠지.” 그래서 for 문으로 세 번 감싸고, 사이에 sleep을 하나 넣습니다.

이게 위험합니다. 실패에는 다시 시도하면 성공하는 것과, 백 번을 해도 똑같은 것이 있습니다. 후자를 반복하면 응답 시간만 늘어나고, 최악의 경우 API 제공자로부터 호출량 제한에 걸립니다. 문제를 해결하려던 재시도가 새로운 문제를 만드는 셈입니다.

블로그 자동 발행 서비스를 운영하면서 적용한 재시도 정책은 실패 원인을 먼저 나누는 방식이었습니다. Python과 httpx를 예로 들지만, 원리는 어떤 언어에서든 같습니다. HTTP 상태코드의 의미를 어느 정도 알고 있다고 가정합니다.

상태코드별 처리표

결론부터 놓겠습니다. 제가 실제로 쓰고 있는 정책입니다.

상태코드 의미 처리
400 요청이 잘못됨 재시도하지 않음
401 인증 실패 토큰 갱신 후 1회만 재시도
403 권한 없음 재시도하지 않음
404 대상이 없음 재시도하지 않음
429 호출량 초과 Retry-After 확인 후 짧으면 대기, 길면 포기
5xx 서버 쪽 문제 GET·HEAD 등 멱등 요청은 백오프로 재시도. 생성·결제 같은 POST는 중복 방지 조건을 확인한 뒤 재시도

재시도 여부는 상태코드만으로 정하지 않습니다. 같은 요청을 다시 보내도 중복 부작용이 없는지를 함께 봅니다. GET·HEAD처럼 멱등한 요청은 일시적 5xx에서 재시도하기 쉽지만, 글 생성·결제 같은 POST는 서버가 이미 처리했는데 응답만 끊겼을 수 있습니다. 이 경우에는 idempotency key를 사용하거나 기존 생성 결과를 조회해 처리 여부를 확인한 뒤 재시도해야 합니다.

왜 이렇게 나눴나

400, 403, 404는 내 요청이 문제입니다. 필드 이름을 틀렸거나, 권한이 없거나, 없는 글을 수정하려 한 것입니다. 똑같은 요청을 다시 보내면 똑같은 응답이 옵니다. 여기서 재시도하는 건 시간 낭비일 뿐 아니라, 실패의 원인을 로그에서 늦게 발견하게 만듭니다. 즉시 실패하는 편이 오히려 친절합니다.

401은 예외입니다. 액세스 토큰이 만료됐을 뿐이라면, 새 토큰을 받아 다시 보내면 성공합니다. 그래서 401은 딱 한 번, 토큰을 버리고 새로 받은 뒤 재시도합니다.

if status == 401 and not token_refreshed:
    invalidate_token()       # 캐시된 토큰 폐기
    token_refreshed = True   # 이 플래그가 핵심
    continue                 # 대기 없이 즉시 재시도

token_refreshed 플래그가 중요합니다. 새 토큰으로 보냈는데 또 401이라면, 그건 만료 문제가 아니라 계정 권한이나 설정 자체가 잘못된 것입니다. 이때 계속 재시도하면 무한 루프에 빠집니다. 그래서 한 번만 허용합니다.

5xx는 일시적인 서버 문제일 수 있지만 요청 방식까지 확인해야 합니다. GET·HEAD처럼 다시 보내도 같은 효과를 기대할 수 있는 요청은 지수 백오프와 지터를 적용할 수 있습니다. 반면 생성·결제 같은 POST는 결과가 불명확하면 자동 재시도를 멈추고, 리소스 조회나 idempotency key로 첫 요청의 처리 여부를 먼저 확인합니다.

429: 기다리라고 알려주는 경우가 있습니다

429는 호출량 제한에 걸렸다는 뜻입니다. 여기서 흔한 실수가 바로 다시 보내는 것입니다. 제한에 걸렸는데 또 두드리면 제한이 더 길어질 수 있습니다.

많은 API가 429 응답에 Retry-After 헤더를 함께 보냅니다. “몇 초 뒤에 다시 오라”는 안내입니다. 이걸 읽습니다.

from datetime import datetime, timezone
from email.utils import parsedate_to_datetime

def retry_after_seconds(value):
    if not value:
        return None
    try:
        return max(0.0, float(value))
    except ValueError:
        try:
            when = parsedate_to_datetime(value)
            if when.tzinfo is None:
                when = when.replace(tzinfo=timezone.utc)
            return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
        except (TypeError, ValueError, OverflowError):
            return None

retry_after = retry_after_seconds(response.headers.get("Retry-After"))
if retry_after is not None and retry_after <= MAX_WAIT:
    await asyncio.sleep(retry_after)
    continue
raise QuotaExceeded()

상한(MAX_WAIT)을 두는 이유가 있습니다. 서버가 “10분 뒤에 오라”고 하면, 그 10분을 기다리는 건 재시도가 아니라 서비스가 멈춰 있는 것입니다. 저는 짧은 대기만 허용하고, 그보다 길면 실패로 처리해 로그를 남긴 뒤 다음 작업으로 넘어갑니다. 기다릴 수 없는 실패는, 빨리 실패하는 게 낫습니다.

Retry-After 헤더가 아예 없는 경우도 마찬가지로 즉시 포기합니다. 얼마를 기다려야 할지 모르는 상태에서 짐작으로 기다리는 건 근거가 없습니다.

지수 백오프와 지터

5xx 재시도에 쓰는 두 가지 장치입니다. 이름은 거창한데, 하는 일은 단순합니다.

지수 백오프(exponential backoff)는 재시도할 때마다 대기 시간을 늘리는 것입니다. 0.5초 → 1초 → 2초 하는 식입니다. 서버가 힘들어하고 있는데 같은 간격으로 계속 두드리면 회복을 방해합니다. 갈수록 뜸하게 두드려서 숨 쉴 틈을 줍니다.

지터(jitter)는 대기 시간에 약간의 무작위값을 섞는 것입니다. 이게 왜 필요하냐면, 여러 요청이 동시에 실패했을 때 문제가 생기기 때문입니다. 모두가 정확히 1초 뒤에 재시도하면, 1초 뒤에 또 한꺼번에 몰려갑니다. 겨우 일어서던 서버가 다시 넘어집니다. 대기 시간을 조금씩 흩뿌려서 이걸 막습니다.

delay = min(BASE * (2 ** attempt), MAX_DELAY)
delay += random.uniform(0, JITTER)   # 이 한 줄이 지터
await asyncio.sleep(delay)

대기 시간에 상한(MAX_DELAY)을 두는 것도 잊지 마세요. 지수는 생각보다 빨리 커집니다.

재시도 횟수에 상한을 두는 이유

저는 총 시도 횟수를 세 번으로 제한했습니다. 왜 무한히 하지 않느냐면, 재시도는 공짜가 아니기 때문입니다.

재시도하는 동안 그 작업은 자원을 붙들고 있습니다. 실패가 계속 쌓이면 대기 중인 작업이 늘어나고, 결국 전체 서비스가 느려집니다. 한 번의 실패를 끝까지 살려보려다 나머지 전부를 망치는 구조입니다.

그래서 정해진 횟수 안에 안 되면 깨끗하게 실패시키고, 대신 로그를 잘 남깁니다. 어떤 요청이 어떤 상태코드로 몇 번 시도 후 실패했는지가 남아 있으면, 나중에 원인을 찾을 수 있습니다. 조용히 무한 재시도하는 코드는 아무것도 남기지 않습니다.

타임아웃도 같은 맥락입니다. 응답이 안 오는 요청을 하염없이 기다리지 않도록, 호출마다 시간 제한을 걸어둡니다.

async with httpx.AsyncClient(timeout=10.0) as client:
    response = await client.post(url, json=payload)

정리

재시도 로직을 만들 때 확인할 것들입니다.

  • □ 모든 실패를 똑같이 재시도하고 있지 않은가 — 4xx는 대부분 포기해야 한다
  • □ 401 재시도에 횟수 제한이 있는가 — 없으면 무한 루프
  • □ 429에서 Retry-After를 읽고 있는가
  • □ 대기 시간이 고정값은 아닌가 — 지수 백오프 + 지터
  • □ 최대 시도 횟수와 타임아웃이 정해져 있는가
  • □ 실패했을 때 상태코드와 시도 횟수가 로그에 남는가
  • □ POST 재시도 전에 중복 생성 방지 또는 기존 처리 결과 확인 절차가 있는가

좋은 재시도 로직의 목표는 “어떻게든 성공시키는 것”이 아닙니다. 성공할 수 있는 실패만 다시 시도하고, 나머지는 빠르게 포기하는 것입니다. 포기를 잘하는 코드가 결국 더 안정적으로 돌아갑니다.

관련 글:
Blogger API와 OAuth 2.0으로 블로그 글 자동 발행하기,
httpx AsyncClient로 외부 API 비동기 호출하기,
Docker .env를 고쳤는데 왜 안 바뀔까

비슷한 문제로 고생하고 계셨다면, 구독이나 공유로 알려 주세요.