외부 API 하나가 응답을 안 주자 배치 작업이 밤새 멈춰 있던 이유
문제 발생
매일 새벽에 도는 동기화 스크립트가 아침이 되도록 끝나 있지 않았습니다. 에러 로그는 한 줄도 없었고, 프로세스도 멀쩡히 살아 있었습니다. 따라가 보니 외부 API를 부르는 이 한 줄에서 멈춰 있었습니다.
resp = requests.get(f"{BASE}/items", params={"page": page})상대 서버가 연결은 받아 놓고 응답을 보내지 않는 상태였는데, 우리 쪽은 그걸 몇 시간째 얌전히 기다리고 있었습니다. 실패라도 했으면 알림이 왔을 텐데, 아무 일도 일어나지 않으니 아무도 몰랐습니다.
원인 분석
requests에는 기본 타임아웃이 없습니다. 문서에 그대로 적혀 있습니다 — 타임아웃 값을 명시적으로 주지 않으면 요청은 시간 초과되지 않습니다. 빠른 시작 문서는 더 세게 말합니다. 거의 모든 프로덕션 코드는 거의 모든 요청에 이 파라미터를 써야 하고, 그러지 않으면 프로그램이 무기한 멈출 수 있다고요.
timeout을 줘도 "전체 시간 제한"은 아닙니다. 여기서 한 번 더 헷갈렸습니다. 문서에 따르면 timeout은 응답 전체를 받는 데 걸리는 시간의 상한이 아니라, 서버가 timeout초 동안 아무 응답도 보내지 않을 때(정확히는 소켓에서 그 시간 동안 1바이트도 받지 못했을 때) 예외를 냅니다. read 타임아웃은 서버가 보내는 바이트 사이를 기다리는 시간이라, 서버가 조금씩이라도 계속 보내면 요청 전체는 지정한 시간보다 훨씬 길어질 수 있습니다. 문서도 connect와 read 타임아웃 둘 다 벽시계 시간이 아니라고 따로 짚어 둡니다.
해결 방안
- 모든 요청에
timeout을 줍니다. 값 하나를 주면 connect와 read 타임아웃에 같이 적용되고, 튜플로 주면 따로 정할 수 있습니다.
resp = requests.get(url, params=params, timeout=(3.05, 27))connect 값이 3이 아니라 3.05인 데는 이유가 있습니다. 문서는 connect 타임아웃을 3의 배수보다 조금 크게 잡으라고 권하는데, 3초가 TCP 패킷 재전송의 기본 간격이기 때문입니다.
- 빠뜨리지 않게 한 군데로 모읍니다. 호출하는 곳마다 적다 보면 반드시 하나는 빠집니다. 외부 API 호출을 감싸는 함수를 하나 두고, 코드에서는 그 함수만 부르게 합니다.
DEFAULT_TIMEOUT = (3.05, 27)
def call_api(method, path, **kwargs):
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
return requests.request(method, f"{BASE}{path}", **kwargs)- 타임아웃이 나면 다음으로 넘어가게 합니다. 시간이 초과되면
requests.exceptions.Timeout이 발생합니다. 배치라면 그 페이지를 실패로 남기고 다음으로 넘어가거나, 정해 둔 횟수만큼만 다시 시도합니다. 예외를 잡지 않으면 이번엔 멈추는 대신 배치 전체가 죽습니다.
try:
resp = call_api("GET", "/items", params={"page": page})
except requests.exceptions.Timeout:
failed_pages.append(page)
continue-
배치 전체의 시간 상한은 따로 겁니다. read 타임아웃은 바이트 사이의 간격이라 "이 작업은 30분 안에 끝난다"를 보장해 주지 않습니다. 작업 전체에 대한 제한은 스케줄러나 실행 쪽에서 따로 둡니다.
-
timeout=None은 기본값과 같다는 걸 기억합니다. 문서는 정말 느린 서버라면None을 줘서 영원히 기다리게 할 수 있다고 소개하는데, 그게 바로 아무것도 안 적었을 때의 동작입니다. 이번 사고가 정확히 그 상태였습니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.