본문 바로가기
빌드노트 검색

빌드노트

fetch가 404를 받아도 catch로 들어가지 않는다

fetch의 Promise 이행과 HTTP 성공은 별개다. Node 내장 fetch로 200·404·500 JSON과 끊긴 연결을 비교하고, HTTP 상태와 오류 본문을 보존하면서 요청 실패와 본문 읽기 실패를 나누는 코드를 확인한다.

fetch()로 JSON API를 호출했는데 서버가 404를 보내도 catch가 실행되지 않을 수 있다. fetch()의 Promise가 이행됐다는 것은 Response를 받았다는 뜻이며, HTTP 상태가 성공이라는 뜻은 아니다. await fetch(...) 바로 다음에서 response.ok와 response.status를 확인해야 한다.

예를 들어 fetch(url).then(r => r.json()).catch(handleError)에서 404 본문이 올바른 JSON이면 두 단계가 모두 이행된다. catch만 두고 HTTP 오류 처리를 기다리면 놓치는 이유다. MDN의 fetch 설명 (새 탭에서 열림)도 HTTP 오류 상태만으로는 Promise가 거절되지 않으며, 응답의 상태를 직접 검사해야 한다고 설명한다.

404의 오류 본문도 읽을 수 있었다

Windows의 Node.js 22.23.2 내장 fetch로 작은 loopback 서버를 요청했다. 실제 서비스 장애가 아니라 200·404·500 JSON과 연결 종료를 만들어 비교한 실험이다. 해당 버전의 Node 문서 (새 탭에서 열림)에 따르면 내장 fetch는 Undici를 기반으로 하며, 실행 환경의 내장 Undici는 6.28.0이었다.

각 주소에 같은 요청 코드를 적용하고, fetch()의 완료와 뒤이은 response.json()의 완료를 따로 기록했다. 아래는 결과에서 상태와 본문만 추린 것이다.

/ok       fetch=fulfilled  ok=true   status=200  json={"id":1}
/missing  fetch=fulfilled  ok=false  status=404  json={"code":"ITEM_NOT_FOUND"}
/error    fetch=fulfilled  ok=false  status=500  json={"code":"TEMPORARY_FAILURE"}
/drop     fetch=rejected   TypeError: fetch failed

404와 500에서도 Response가 있었고 json()은 정상 완료됐다. HTTP 오류라고 본문까지 읽을 수 없는 것은 아니다. 서버가 보낸 오류 코드를 쓰려면 이 응답을 HTTP 실패 분기에서 받아야 한다.

반면 /drop은 HTTP 헤더를 보내기 전에 서버가 연결을 끊었다. 이 경우에는 fetch()가 거절돼 Response를 얻지 못했고, 본문 읽기를 시도하지 않았다. 반환된 응답의 status가 0이었던 것이 아니라 응답 객체 자체가 없었다. Fetch 표준의 fetch 알고리즘 (새 탭에서 열림)은 네트워크 오류이면 Promise를 거절하고, 그렇지 않으면 생성한 Response로 이행하는 단계를 구분한다.

상태를 보존한 뒤 본문을 읽는다

자기 코드에서는 await fetch(...)와 await response.json()을 한 try로 감쌌는지 먼저 본다. 둘을 함께 잡은 catch만으로는 응답을 얻지 못한 실패와 본문을 읽다가 난 실패를 구별하기 어렵다. 또 response.json()의 반환값만 다음 함수로 넘기면 그 자리에는 HTTP 상태가 남지 않는다.

아래 함수는 그 경계를 나눈다. 기존 요청의 url과 options를 readItem(url, options)에 넘겨 사용한다. url은 읽기 전용 JSON API 주소이며 Node에서는 절대 URL이어야 한다. options에는 원래 쓰던 헤더나 취소 신호를 전달하고, 필요 없으면 생략한다. 성공 응답에도 JSON 본문이 온다는 계약을 전제로 한 코드다.

export async function readItem(url, options = {}) {
  let response;
  try {
    response = await fetch(url, options);
  } catch (error) {
    return { kind: 'request-error', error };
  }

  const { ok, status } = response;
  let body;
  try {
    body = await response.json();
  } catch (bodyError) {
    return {
      kind: ok ? 'body-error' : 'http-error',
      status, body: null, bodyError,
    };
  }
  return { kind: ok ? 'success' : 'http-error', status, body };
}

ok는 상태가 200~299일 때 참이다. 특정 상태를 따로 처리해야 한다면 status를 함께 사용한다. 이 범위와 ok의 관계는 Fetch 표준의 Response 정의 (새 탭에서 열림)에 명시돼 있다.

같은 서버에 이 함수를 적용하니 /ok는 success, /missing과 /error는 http-error, /drop은 request-error로 나뉘었다. 404의 body.code는 ITEM_NOT_FOUND, 500의 값은 TEMPORARY_FAILURE로 보존됐다. 호출하는 쪽은 kind로 먼저 분기한 뒤, HTTP 실패 안에서 status와 오류 본문을 보고 처리할 수 있다. 예를 들어 404일 때 항목 없음 안내를 선택할 수 있다.

request-error에는 HTTP 상태를 넣지 않았다. 이 실험에서는 연결 종료가 원인이었지만, 이 분기 이름만 보고 모든 실패를 서버 장애로 단정해서는 안 된다. 잘못된 URL이나 요청 취소 여부도 원래 오류와 요청 조건을 통해 확인해야 한다.

본문 읽기 실패가 HTTP 상태를 지우면 안 된다

500 헤더와 JSON 본문의 일부를 보낸 뒤 연결을 끊는 조건도 실행했다. fetch()는 이미 이행됐고 status=500이었지만 json()은 TypeError로 거절됐다. 위 함수는 이 경우에도 kind: 'http-error', status: 500을 유지하고, body를 null로 두며 bodyError에 읽기 오류를 남겼다.

404와 함께 JSON 대신 HTML을 보낸 경우에는 json()이 SyntaxError로 실패했다. 이때도 404는 유지됐다. 같은 HTML을 200으로 보내면 body-error가 됐다. HTTP 상태가 실패인지와 본문을 JSON으로 읽을 수 있는지는 별개의 판단이다. MDN의 json 설명 (새 탭에서 열림)은 이 메서드가 본문 스트림을 끝까지 읽고 파싱하며, JSON으로 해석할 수 없으면 실패한다고 설명한다.

오류 본문을 못 읽었다고 404·500을 연결 실패로 바꾸지 말고, 이미 받은 상태와 본문 오류를 함께 남긴다. 성공 응답에 본문이 없는 API라면 해당 계약에 따라 파싱을 생략하는 분기가 별도로 필요하다.

실행한 범위는 Node 내장 fetch와 합성 loopback 응답이다. 브라우저 실행, CORS 차단, opaque 응답은 검증하지 않았다. 이 실험의 오류 문구나 상태 검사만으로 그 조건들까지 처리할 수 있다고 일반화하지 않는다.

← 전체 글 보기