Published on

Python httpx RemoteProtocolError 서버 끊김 원인과 해결

Authors

서버 호출을 httpx로 옮긴 뒤, 특정 구간에서만 간헐적으로 아래 예외가 터지는 경우가 있습니다.

httpx.RemoteProtocolError: Server disconnected without sending a response

이 에러는 “서버가 응답 바이트를 보내기 전에 연결이 끊겼다”는 뜻이지만, 원인은 서버 자체 다운부터 L7/L4 로드밸런서, 프록시, HTTP/2, keep-alive, 타임아웃, MTU/네트워크 정책까지 매우 넓습니다. 이 글에서는 증상→원인 후보→관측 방법→해결책 순서로, 운영 환경에서 바로 적용 가능한 체크리스트와 코드 예제를 제공합니다.

1) RemoteProtocolError가 의미하는 것 (httpx 관점)

RemoteProtocolError는 대개 다음 상황에서 발생합니다.

  • TCP 연결은 맺었지만(또는 기존 keep-alive 커넥션을 재사용했지만)
  • 서버/중간 장비가 HTTP 응답을 시작하기 전에 연결을 종료함
  • 혹은 HTTP/2 스트림/프레임 레벨에서 프로토콜 위반 또는 강제 종료가 발생함

중요한 포인트는 “서버 애플리케이션이 500을 반환했다” 같은 정상적인 HTTP 응답이 아니라, 연결이 중간에 끊겨 클라이언트가 응답을 파싱할 수 없게 된 상황이라는 점입니다.

2) 가장 흔한 원인 Top 7

2.1 Keep-Alive 커넥션 재사용 중, 서버가 먼저 끊은 경우

로드밸런서/서버가 idle timeout으로 커넥션을 정리했는데, 클라이언트 풀에서 그 커넥션을 재사용하면 첫 요청에서 끊김이 발생할 수 있습니다.

  • ALB/NLB/NGINX/Envoy idle timeout
  • 서버 keep-alive timeout이 짧음
  • NAT/방화벽이 유휴 세션을 정리

특징: 첫 요청만 실패하고, 재시도하면 성공하는 패턴이 많습니다.

2.2 HTTP/2 협상/프록시/게이트웨이의 미묘한 호환성

httpx는 환경에 따라 HTTP/2를 사용합니다(옵션/전송계층에 따라). 중간 프록시가 HTTP/2를 완전하게 처리하지 못하면 스트림이 끊기며 RemoteProtocolError로 보일 수 있습니다.

2.3 서버/게이트웨이의 request body 처리 중 조기 종료

대용량 업로드, 스트리밍, chunked encoding 처리에서 서버가 제한을 만나거나(최대 바디 크기), 백엔드가 죽어 연결을 닫을 수 있습니다.

2.4 타임아웃 설정이 모호하거나 너무 공격적인 경우

timeout=5 같은 단일 숫자 설정은 connect/read/write/pool에 동일하게 적용되어, 특정 단계에서 예상치 못한 끊김/예외를 유발할 수 있습니다(특히 큰 응답, 느린 TLS 핸드셰이크, 프록시 경유).

2.5 프록시/Ingress/LB가 특정 헤더/메서드에서 연결을 끊는 경우

예: Expect: 100-continue, Connection: keep-alive, Transfer-Encoding 조합, 또는 WAF 규칙에 걸려 “응답 없이” 종료.

2.6 DNS는 되는데 HTTPS만 실패하는 네트워크 이슈

Kubernetes/EKS 환경에서는 DNS는 정상인데 TLS/HTTPS만 간헐 실패하는 케이스가 실제로 많습니다(SNAT, NACL, MTU, 경로 MTU discovery 등).

관련해서는 아래 글의 네트워크 체크 포인트가 그대로 도움이 됩니다.

2.7 서버의 worker timeout/프로세스 강제 종료

백엔드가 Gunicorn/Uvicorn 조합이라면, worker timeout으로 프로세스가 죽으면서 연결이 끊기는 형태로 나타날 수 있습니다.

3) 재현/관측: “무슨 단계에서 끊겼는지” 먼저 잡기

3.1 httpx 로깅 켜서 요청/응답 흐름 보기

import logging
import httpx

logging.basicConfig(level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.DEBUG)
logging.getLogger("httpcore").setLevel(logging.DEBUG)

with httpx.Client(timeout=10.0) as client:
    r = client.get("https://example.com")
    print(r.status_code)
  • httpcore 로그에서 커넥션 재사용 여부, TLS 핸드셰이크, HTTP/2 사용 여부 단서를 얻을 수 있습니다.

3.2 curl로 HTTP/2 강제/비활성 비교

# HTTP/2 강제
curl -v --http2 https://your-api.example.com/health

# HTTP/1.1 강제
curl -v --http1.1 https://your-api.example.com/health
  • HTTP/2에서만 끊기면: 프록시/Ingress/서버의 H2 처리 의심
  • HTTP/1.1에서도 동일하면: keep-alive/idle timeout/네트워크/서버 다운 의심

3.3 (K8s라면) Ingress/Envoy/NGINX 로그에서 “upstream prematurely closed” 확인

NGINX Ingress에서 흔한 단서:

  • upstream prematurely closed connection while reading response header from upstream
  • client prematurely closed connection

이런 로그가 있으면 “누가 먼저 끊었는지”를 좁힐 수 있습니다.

4) 해결책: 클라이언트에서 할 수 있는 안정화 옵션

아래는 서버를 못 건드리거나, 원인 파악 전이라도 장애를 줄이는 실전 옵션입니다.

4.1 타임아웃을 단계별로 명시하기

단일 숫자 대신 httpx.Timeout으로 분리하면, 어디가 병목인지도 드러나고 불필요한 끊김도 줄일 수 있습니다.

import httpx

timeout = httpx.Timeout(
    connect=5.0,   # TCP/TLS 연결
    read=30.0,     # 응답 바디 읽기
    write=30.0,    # 요청 바디 쓰기
    pool=5.0       # 커넥션 풀 대기
)

with httpx.Client(timeout=timeout) as client:
    r = client.get("https://your-api.example.com/v1/data")
    r.raise_for_status()
    print(r.json())

4.2 HTTP/2를 꺼서 호환성 문제 우회

중간 장비가 H2를 불안정하게 처리한다면, 우선 HTTP/1.1로 고정해 장애를 줄일 수 있습니다.

import httpx

with httpx.Client(http2=False, timeout=20.0) as client:
    r = client.get("https://your-api.example.com")
    print(r.status_code)

반대로 서버가 H2에서 더 안정적이라면 http2=True로 명시해 일관성을 확보하는 것도 방법입니다.

4.3 커넥션 풀/keep-alive 튜닝

idle timeout 불일치로 재사용 커넥션이 자주 터진다면, 풀 정책을 조정하거나 아예 keep-alive를 줄여볼 수 있습니다.

import httpx

limits = httpx.Limits(
    max_connections=100,
    max_keepalive_connections=20,
    keepalive_expiry=10.0,  # 초
)

with httpx.Client(limits=limits, timeout=20.0) as client:
    for _ in range(1000):
        client.get("https://your-api.example.com/ping")
  • keepalive_expiry로드밸런서 idle timeout보다 짧게 잡으면 “죽은 커넥션 재사용” 확률이 줄어듭니다.

4.4 재시도(Retry) + 지수 백오프 + idempotency 고려

RemoteProtocolError는 네트워크/중간장비 요인으로 일시적인 경우가 많아 재시도가 효과적입니다. 단, POST/결제/상태 변경 요청은 멱등성 보장이 필요합니다.

아래는 tenacity를 이용한 예시입니다.

import httpx
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

retryable = (
    httpx.RemoteProtocolError,
    httpx.ReadTimeout,
    httpx.ConnectTimeout,
    httpx.ConnectError,
)

@retry(
    reraise=True,
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=0.3, min=0.3, max=3.0),
    retry=retry_if_exception_type(retryable),
)
def fetch(client: httpx.Client, url: str) -> dict:
    r = client.get(url)
    r.raise_for_status()
    return r.json()

with httpx.Client(timeout=20.0) as client:
    data = fetch(client, "https://your-api.example.com/v1/resource")
    print(data)
  • POST라면 Idempotency-Key를 서버가 지원하는지 확인하고, 키를 넣어 재시도 안전성을 확보하세요.

4.5 스트리밍 응답이라면 “끊김 복구” 전략이 필요

SSE/Chunked/스트리밍은 중간에 끊기는 것이 전제인 환경도 많습니다. 이 경우 단순 재시도보다 체크포인팅(마지막 수신 지점) 기반 복구가 중요합니다.

스트리밍 끊김/타임아웃/RemoteProtocolError 복구 패턴은 아래 글이 매우 직접적으로 연결됩니다.

5) 서버/인프라에서 확인해야 할 것 (원인 제거)

클라이언트 완화만으로는 한계가 있어, 재발을 막으려면 서버/인프라 쪽에서 “왜 응답 없이 끊었는지”를 찾아야 합니다.

5.1 로드밸런서/Ingress idle timeout 정렬

  • LB idle timeout < 서버 keep-alive timeout이면: 서버는 살려두는데 LB가 먼저 끊음
  • 서버 keep-alive timeout < 클라이언트 keepalive_expiry이면: 클라이언트가 죽은 커넥션 재사용

권장: 클라이언트 keepalive_expiry < (LB idle timeout, 서버 keep-alive timeout) 최소값.

5.2 프록시 버퍼링/바디 제한/헤더 제한

  • NGINX client_max_body_size
  • Envoy max request headers size
  • WAF 차단 규칙(차단 시 응답 없이 close하는 설정이 있는지)

5.3 백엔드 worker가 죽는지 확인

  • OOMKill, worker timeout, CPU throttling
  • 요청이 길어질 때만 끊기면 타임아웃/리소스 문제 가능성이 큼

6) 운영에서 바로 쓰는 “진단 체크리스트”

  1. HTTP/1.1 vs HTTP/2 비교(curl --http1.1/--http2)
  2. httpcore DEBUG 로그로 커넥션 재사용 여부 확인
  3. keep-alive 만료 정렬: 클라이언트 keepalive_expiry를 짧게
  4. 타임아웃을 단계별로 분리하고, read/write를 넉넉히
  5. 재시도는 2~3회 + 지수 백오프(POST는 멱등성 키)
  6. K8s/EKS라면 DNS OK + HTTPS FAIL 유형 점검(SNAT/MTU/NACL)
  7. 서버 로그에서 worker timeout/OOM/게이트웨이 조기 종료 흔적 확인

7) 결론: “끊김”은 버그가 아니라 설계 대상

httpx.RemoteProtocolError: Server disconnected...는 단순히 클라이언트 예외가 아니라, 네트워크/중간장비/서버 타임아웃의 불일치가 드러난 신호인 경우가 많습니다.

  • 빠른 완화: Timeout 분리 + keepalive 튜닝 + (필요 시) HTTP/2 비활성 + 재시도
  • 근본 해결: LB/Ingress/서버 timeout 정렬 + worker 안정화 + K8s 네트워크 이슈 제거

위 순서대로 접근하면 “가끔 터지는” 애매한 장애를 재현 가능한 문제로 바꾸고, 안정적인 호출 품질을 만들 수 있습니다.