Published on

Python httpx ReadTimeout·ConnectError 재시도 설계

Authors

서버/외부 API를 호출하는 Python 서비스에서 장애의 80%는 “요청이 실패했다”가 아니라 “언제까지 기다릴지/어떻게 다시 시도할지”를 잘못 설계해서 커집니다. 특히 httpx를 쓰면 ReadTimeout(응답 지연)과 ConnectError(연결 자체 실패)가 빈번히 나타나는데, 둘은 성격이 달라 같은 정책으로 재시도하면 오히려 장애를 증폭시킬 수 있습니다.

이 글에서는 httpx에서 ReadTimeout·ConnectError를 구분하고, 멱등성 기반 재시도, 지수 백오프 + 지터, 타임아웃 분리(connect/read/write/pool), Retry-After/429 대응, 동시성 제한까지 포함한 실전 설계를 정리합니다.

관련해서 네트워크 레벨(DNS/프록시)에서 타임아웃이 발생하는 경우도 많습니다. 쿠버네티스 DNS 이슈가 의심된다면 AWS EKS CoreDNS CrashLoopBackOff와 DNS 타임아웃 해결도 함께 확인해두면 좋습니다. 레이트리밋이 섞여 있다면 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기처럼 **재시도보다 먼저 “요청량 제어”**가 필요합니다.

1) httpx에서 ReadTimeout과 ConnectError의 의미

ReadTimeout

  • TCP 연결은 되었고(대개 TLS 핸드셰이크까지 끝남), 응답을 읽는 단계에서 설정한 시간 내에 데이터가 오지 않아 발생
  • 원인 후보
    • 서버 처리 지연(백엔드/DB/외부 연동)
    • 프록시/로드밸런서의 idle timeout
    • 스트리밍(SSE)에서 중간 버퍼링/플러시 문제
    • 네트워크 패킷 손실/혼잡

ConnectError

  • 연결 자체가 성립하지 않음
  • 원인 후보
    • DNS 실패/지연
    • SYN 실패, 라우팅/보안그룹/방화벽 차단
    • 서버 포트 미오픈, 커넥션 폭주(accept backlog)
    • 프록시 연결 실패

둘의 차이는 재시도 정책에 직접 영향을 줍니다.

  • ConnectError: 대체로 짧은 백오프 + 빠른 재시도가 유효(일시적 네트워크/포트 혼잡)
  • ReadTimeout: 서버가 이미 요청을 처리 중일 수 있어, 특히 **비멱등 요청(POST 등)**은 재시도가 “중복 처리”를 만들 수 있음

2) 재시도 설계의 핵심: “무조건 재시도”가 아니라 “조건부 재시도”

재시도는 다음 4가지를 먼저 결정해야 합니다.

  1. 무엇을 재시도할지(예외/상태코드)
  • 예외: httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError
  • 상태코드: 408/409/425/429/500/502/503/504 (서비스 성격에 따라 조정)
  1. 어떤 요청을 재시도할지(멱등성)
  • 안전: GET/HEAD/OPTIONS는 보통 OK
  • 주의: POST/PATCH는 기본적으로 위험
  • 예외: 서버가 Idempotency-Key를 지원하거나, 클라이언트가 요청 ID 기반 중복 제거를 보장하면 POST도 안전해질 수 있음
  1. 얼마나/얼마 간격으로 재시도할지(백오프/지터/상한)
  • 지수 백오프(exponential backoff) + 랜덤 지터(jitter)
  • 최대 대기 상한(cap)과 최대 시도 횟수 제한
  1. 재시도 이전에 부하를 줄일 장치가 있는지(동시성 제한/레이트리밋/회로차단)
  • 재시도는 트래픽을 늘립니다. 장애 시에는 특히.

3) httpx 타임아웃을 한 덩어리로 두지 말 것

httpx.Timeout(5.0)처럼 단일 값으로 두면, 문제를 분해하기 어렵습니다. 실무에서는 다음처럼 구간별로 분리하는 편이 안전합니다.

import httpx

TIMEOUT = httpx.Timeout(
    connect=2.0,  # DNS+TCP+TLS 포함(환경에 따라 TLS 포함 여부 체감)
    read=10.0,    # 응답 바디 읽기(스트리밍이면 더 길게)
    write=5.0,    # 요청 바디 업로드
    pool=2.0,     # 커넥션 풀에서 대기
)

LIMITS = httpx.Limits(
    max_connections=100,
    max_keepalive_connections=20,
    keepalive_expiry=30.0,
)

client = httpx.Client(timeout=TIMEOUT, limits=LIMITS)
  • pool 타임아웃은 내 서비스 내부에서 커넥션 풀이 고갈될 때 빨리 실패하게 해줍니다.
  • read 타임아웃은 서버 처리 시간과 프록시 idle timeout을 고려해 잡습니다.
  • 스트리밍(SSE)라면 read timeout을 길게 주거나, “첫 바이트까지의 타임아웃(TTFB)”과 “스트림 idle 타임아웃”을 분리하는 설계를 고민해야 합니다. 프록시 계층 이슈가 의심되면 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트도 참고하세요.

4) 재시도 정책: 예외/상태코드/멱등성 기반으로 분기

아래 예시는 동기 httpx.Client 기준으로, 다음 원칙을 구현합니다.

  • ConnectError는 비교적 공격적으로 재시도
  • ReadTimeout은 멱등 요청에만 재시도(기본)
  • 429/503 등은 Retry-After를 존중
  • 백오프는 지수 + 지터, 최대 대기 상한
from __future__ import annotations

import random
import time
from dataclasses import dataclass
from typing import Iterable, Optional

import httpx

RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
IDEMPOTENT_METHODS = {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}


@dataclass
class RetryPolicy:
    max_attempts: int = 4
    base_delay: float = 0.2        # seconds
    max_delay: float = 3.0         # seconds
    jitter: float = 0.2            # 0.0~1.0: delay에 곱해질 랜덤 비율

    def backoff(self, attempt: int) -> float:
        # attempt: 1,2,3...
        delay = min(self.max_delay, self.base_delay * (2 ** (attempt - 1)))
        # full jitter 변형: delay 범위 내 랜덤
        return random.uniform(0, delay * (1.0 + self.jitter))


def parse_retry_after(resp: httpx.Response) -> Optional[float]:
    ra = resp.headers.get("Retry-After")
    if not ra:
        return None
    try:
        return float(ra)
    except ValueError:
        return None


def is_idempotent(method: str) -> bool:
    return method.upper() in IDEMPOTENT_METHODS


def should_retry_exception(exc: Exception, method: str) -> bool:
    # 연결 실패는 대체로 재시도 가치가 큼
    if isinstance(exc, httpx.ConnectError):
        return True

    # 읽기 타임아웃은 “이미 서버가 처리했을 수도” 있으므로 멱등 요청만
    if isinstance(exc, httpx.ReadTimeout):
        return is_idempotent(method)

    # 프로토콜 오류/일시적 네트워크 오류도 상황에 따라 재시도 가능
    if isinstance(exc, (httpx.RemoteProtocolError, httpx.PoolTimeout)):
        return is_idempotent(method)

    return False


def should_retry_response(resp: httpx.Response, method: str) -> bool:
    if resp.status_code not in RETRYABLE_STATUS:
        return False
    # 429/503 등은 POST라도 서버가 “다시 시도”를 요구하는 경우가 있어
    # Idempotency-Key를 쓸 수 있으면 POST도 허용하는 편이 현실적.
    if is_idempotent(method):
        return True
    return resp.status_code in {429, 503}


def request_with_retry(
    client: httpx.Client,
    method: str,
    url: str,
    *,
    policy: RetryPolicy = RetryPolicy(),
    idempotency_key: Optional[str] = None,
    **kwargs,
) -> httpx.Response:
    headers = dict(kwargs.pop("headers", {}) or {})
    if idempotency_key:
        headers.setdefault("Idempotency-Key", idempotency_key)

    last_exc: Optional[Exception] = None

    for attempt in range(1, policy.max_attempts + 1):
        try:
            resp = client.request(method, url, headers=headers, **kwargs)

            if should_retry_response(resp, method) and attempt < policy.max_attempts:
                retry_after = parse_retry_after(resp)
                sleep_s = retry_after if retry_after is not None else policy.backoff(attempt)
                resp.close()  # 커넥션 반환
                time.sleep(sleep_s)
                continue

            return resp

        except Exception as exc:
            last_exc = exc
            if attempt >= policy.max_attempts or not should_retry_exception(exc, method):
                raise
            time.sleep(policy.backoff(attempt))

    assert last_exc is not None
    raise last_exc

포인트

  • ReadTimeout을 POST에 무조건 재시도하면 중복 주문/중복 결제/중복 작업이 생깁니다.
  • 429/503은 서버가 “지금 하지 말고 기다렸다가 하라”는 의미일 수 있어 Retry-After를 존중합니다.
  • resp.close()는 재시도 전에 커넥션을 풀에 반환하기 위해 중요합니다(특히 스트리밍/큰 바디에서).

5) POST 재시도는 “Idempotency-Key + 서버 중복 제거”가 전제

결론부터 말하면, POST 재시도는 설계 없이는 위험합니다. 다음 중 하나가 있어야 합니다.

  • 서버가 Idempotency-Key를 지원하고, 같은 키로 들어온 요청을 동일 결과로 처리
  • 클라이언트가 요청 ID를 생성하고, 서버가 해당 ID로 “이미 처리됨”을 판별

예시(클라이언트):

import uuid
import httpx

client = httpx.Client(timeout=10.0)

key = str(uuid.uuid4())
resp = request_with_retry(
    client,
    "POST",
    "https://api.example.com/payments",
    idempotency_key=key,
    json={"amount": 1000, "currency": "KRW"},
)
resp.raise_for_status()
print(resp.json())

이렇게 해두면 ReadTimeout이 발생해도 “서버는 처리했는데 응답만 늦은” 상황에서 재시도가 안전해집니다(서버가 키를 진짜로 보장한다는 전제).

6) 동시성/커넥션 풀 고갈이 재시도를 망친다

재시도가 붙으면 순간적으로 트래픽이 늘고, 커넥션 풀이 고갈되며 PoolTimeout이 연쇄적으로 터질 수 있습니다. 다음을 같이 적용하세요.

  • httpx.Limits로 상한 설정
  • 호출부에서 세마포어로 동시성 제한
import threading
import httpx

sema = threading.Semaphore(50)  # 외부 API 동시 호출 상한

client = httpx.Client(
    timeout=httpx.Timeout(connect=2, read=10, write=5, pool=2),
    limits=httpx.Limits(max_connections=60, max_keepalive_connections=20),
)

def safe_call(url: str) -> dict:
    with sema:
        resp = request_with_retry(client, "GET", url)
        resp.raise_for_status()
        return resp.json()

이 조합은 “재시도 때문에 더 망하는 상황(Thundering Herd)”을 크게 줄입니다.

7) 관측(Observability): 재시도는 반드시 로그/메트릭으로 남겨야 한다

재시도는 성공률을 올리지만, 평균 지연시간과 외부 비용을 올립니다. 따라서 최소한 아래를 기록하세요.

  • 예외 타입(ConnectError, ReadTimeout)
  • 시도 횟수(attempt)
  • 총 소요 시간(end-to-end)
  • 상태코드(429/503 등)
  • 업스트림 호스트/리전

간단한 구조화 로그 예시:

import time
import logging
import httpx

log = logging.getLogger("http")

def get_with_metrics(client: httpx.Client, url: str) -> httpx.Response:
    t0 = time.perf_counter()
    try:
        resp = request_with_retry(client, "GET", url)
        dt = time.perf_counter() - t0
        log.info("http_call", extra={"url": url, "status": resp.status_code, "elapsed": dt})
        return resp
    except Exception as e:
        dt = time.perf_counter() - t0
        log.warning("http_call_failed", extra={"url": url, "err": type(e).__name__, "elapsed": dt})
        raise

추후 대시보드에서 ReadTimeout 비율이 늘면 서버 처리 지연/프록시 idle timeout을, ConnectError가 늘면 DNS/네트워크/방화벽/포트 혼잡을 우선 의심할 수 있습니다.

8) 실전 체크리스트

  • ReadTimeoutConnectError를 같은 재시도 정책으로 묶지 않았는가?
  • 타임아웃을 connect/read/write/pool로 분리했는가?
  • POST 재시도에 Idempotency-Key 또는 서버 중복 제거가 있는가?
  • 429/503은 Retry-After를 존중하는가?
  • 재시도에 지수 백오프 + 지터 + 최대 상한이 있는가?
  • 동시성 제한/커넥션 풀 상한을 함께 두었는가?
  • 재시도 횟수/예외 타입/지연시간을 메트릭으로 수집하는가?

마무리

httpx.ReadTimeouthttpx.ConnectError는 겉으로는 “요청 실패”지만, 실제로는 실패 지점이 다르고 그에 따라 재시도 전략도 달라야 합니다. 핵심은 (1) 타임아웃을 분해해 원인을 좁히고, (2) 멱등성/Idempotency-Key로 중복 처리를 막고, (3) 백오프·지터·동시성 제한으로 장애 증폭을 차단하는 것입니다.

이 3가지만 지켜도 “가끔 타임아웃 나는데 그냥 재시도하면 되지” 수준에서, 장애 상황에서도 서비스가 버티는 호출 계층으로 한 단계 올라갈 수 있습니다.