httpx AsyncClient로 외부 API 비동기 호출하기

HTTPX AsyncClient의 연결 풀 재사용과 네 가지 타임아웃, 상태 코드별 오류 처리 및 제한적 재시도 기준을 검증 예제와 함께 설명합니다.

여러 외부 API를 순서대로 호출할 때는 비동기 문법만큼 클라이언트의 수명과 실패 분류가 중요합니다. HTTPX AsyncClient 하나로 연결 풀을 재사용하고, 타임아웃과 상태 코드에 따라 중단·스킵·재시도를 구분할 수 있습니다.

반복문 안에서 클라이언트를 만들면 연결을 재사용하기 어렵습니다

키워드마다 AsyncClient를 새로 열면 연결을 재사용하기 어렵습니다. HTTPX는 Client 인스턴스가 연결 풀을 유지하므로, 반복 호출에서는 클라이언트를 반복문 밖에서 한 번 열어 같은 호스트의 연결을 재사용하는 편이 맞습니다. HTTPX 비동기 지원 문서도 hot loop 안에서 여러 client 인스턴스를 만들지 말라고 안내합니다.

import httpx

timeout = httpx.Timeout(
    10.0,
    connect=5.0,
    read=20.0,
    write=10.0,
    pool=2.0,
)

async def collect_keywords(api_url: str, keywords: list[str]) -> list[dict]:
    results = []
    async with httpx.AsyncClient(timeout=timeout) as client:
        for keyword in keywords:
            response = await client.get(api_url, params={"q": keyword})
            if response.status_code == 404:
                continue
            response.raise_for_status()
            results.append(response.json())
    return results

404가 해당 API에서 “검색 결과 없음”을 뜻한다는 계약이 있을 때만 위처럼 스킵합니다. 인증 실패인 401·403이나 요청 형식 오류인 400·422는 입력 또는 권한을 고쳐야 하므로 자동 재시도로 숨기지 않습니다. 그 밖의 예상하지 못한 4xx·5xx는 raise_for_status()로 HTTPStatusError를 발생시켜 호출자가 처리하게 합니다.

timeout 하나는 서로 다른 네 번의 대기를 포함합니다

HTTPX 타임아웃 문서는 connect, read, write, pool 네 종류를 구분합니다. 모든 호출에 같은 숫자가 적절한 것은 아니므로 어느 단계가 느린지에 맞춰 값을 정합니다.

종류 기다리는 대상 시간 초과 뒤 우선 판단
connect 서버와 연결 수립 주소·DNS·방화벽과 일시적 연결 실패를 확인합니다
read 응답 데이터 수신 서버 처리 지연과 응답 스트림 중단을 확인합니다
write 요청 데이터 전송 업로드 본문의 재전송 안전성을 먼저 확인합니다
pool 연결 풀에서 빈 연결 확보 동시성·pool limit를 점검하며 즉시 재시도를 반복하지 않습니다

AI 생성처럼 응답 시작이 느린 호출은 read를 더 길게 둘 수 있지만, 연결 자체에 같은 긴 시간을 줄 이유는 없습니다. 반대로 대용량 업로드는 write 시간을 별도로 검토해야 합니다.

재시도는 요청이 다시 실행돼도 안전할 때만 적용합니다

다음 예시는 조회용 GET에 한해 연결·읽기 오류와 일부 일시 상태만 제한적으로 재시도합니다. HTTPX transport의 retries 옵션은 ConnectError와 ConnectTimeout만 다루므로, 상태 코드나 읽기 오류 정책은 애플리케이션에서 명시해야 합니다.

import asyncio
import httpx

RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
RETRYABLE_ERRORS = (
    httpx.ConnectError,
    httpx.ConnectTimeout,
    httpx.ReadError,
    httpx.ReadTimeout,
    httpx.RemoteProtocolError,
)

async def get_json(client: httpx.AsyncClient, url: str, *, tries: int = 3):
    for attempt in range(tries):
        try:
            response = await client.get(url)
            response.raise_for_status()
            return response.json()
        except httpx.HTTPStatusError as exc:
            if exc.response.status_code not in RETRYABLE_STATUS or attempt + 1 == tries:
                raise
        except RETRYABLE_ERRORS:
            if attempt + 1 == tries:
                raise
        await asyncio.sleep(0.5 * (2 ** attempt))

POST처럼 서버 상태를 바꿀 수 있는 요청은 API가 idempotency key나 동일 요청의 안전한 재처리를 보장할 때만 같은 정책을 적용합니다. WriteTimeout은 본문 일부가 이미 전달됐을 수 있고, PoolTimeout은 로컬 동시성 과부하일 수 있으므로 위 조회 재시도 목록에서 제외했습니다. 429를 처리할 때는 API가 제공하는 Retry-After와 호출량 제한도 함께 확인합니다.

HTTPX 요청 결과에 따른 스킵, 제한 재시도, 즉시 실패 판단 분기
조회 요청의 결과를 성공, 계약상 404, 제한 재시도 대상, 즉시 실패 대상으로 나눕니다. 재시도 횟수를 소진하면 최종 실패로 처리합니다.

상태 코드를 스킵할지 실패로 남길지 먼저 정합니다

단순히 status_code != 200을 모두 건너뛰면 401 인증 오류나 500 서버 오류가 정상적인 빈 결과처럼 사라집니다. 먼저 API 계약상 허용되는 빈 결과만 분기하고, 나머지는 raise_for_status()로 실패를 드러냅니다.

async def fetch_optional(client: httpx.AsyncClient, url: str):
    response = await client.get(url)
    if response.status_code == 404:  # 이 API에서 404가 "항목 없음"일 때만 사용합니다.
        return None
    response.raise_for_status()
    return response.json()

배치 작업이라면 한 항목의 실패를 기록한 뒤 다음 항목으로 넘어갈 수 있습니다. 다만 스킵한 상태 코드, 시도 횟수, 마지막 예외를 남겨야 데이터 누락과 정상적인 빈 결과를 구분할 수 있습니다.

격리 환경에서 연결과 오류 분기를 확인했습니다

2026년 9월 13일 검증 환경에서 loopback HTTP/1.1 서버에 세 요청을 순서대로 보냈습니다. 하나의 AsyncClient가 세 요청에 같은 TCP 연결 한 개를 사용했습니다. MockTransport로 503→200과 ConnectTimeout→200은 두 번째 시도에서 성공했고, 404·WriteTimeout·PoolTimeout은 한 번 호출 뒤 그대로 실패하는 것도 확인했습니다.

이 결과는 특정 외부 API의 가용성이나 적정 타임아웃 값을 측정한 것이 아닙니다. 확인한 범위는 연결 풀 재사용과 예제의 분기 동작입니다. 예외 계층은 HTTPX Exceptions, client 연결 풀은 HTTPX Clients, 격리 응답을 만든 방식은 HTTPX Transports에서 확인할 수 있습니다.

요청 제한 시간과 예외를 구분하는 방법은 HTTPX AsyncClient TimeoutException 원인과 timeout 설정 방법에서 자세히 다룹니다.

주기 실행과 결합할 때는 FastAPI에서 APScheduler로 주기 작업 구현하기에서 작업 중복과 종료 처리를 함께 확인합니다. 외부 API가 쿼터를 제공한다면 YouTube Data API 쿼터 초과를 피하는 방법처럼 재시도와 호출량 제한을 같은 정책으로 관리합니다.