orval 의 fetch 클라이언트는 500 을 성공으로 넘긴다 — mutator 로 throw 주입
httpClient fetch 로 생성한 코드는 res.ok 를 확인하지 않고 status 무관하게 resolve 한다. react-query 는 throw 해야 에러로 인식하므로 4xx/5xx 가 전부 isSuccess 가 된다.
orval 로 OpenAPI 스펙에서 API 클라이언트를 생성해 TanStack Query 훅으로 쓰고 있었다. 설정은 client: 'react-query', httpClient: 'fetch'.
서버가 500 을 반환해도 useQuery().isError 가 false 였다. isSuccess 가 true 로 떴다.
에러 화면도, ApiError 매핑도, 인증 실패 시 리다이렉트하는 라우터 가드도 전부 무력화된 상태였다. 에러 처리 코드는 다 짜여 있는데 아무것도 호출되지 않는다.
원인 — 생성된 코드가 throw 하지 않는다
orval 의 기본 fetch 생성 코드를 열어보면 res.ok 를 확인하지 않는다.
// 생성된 코드 (mutator 없음)
export const listUsers = async (...): Promise<listUsersResponse> => {
const res = await fetch(getListUsersUrl(params), { ...options, method: 'GET' })
const body = [204,205,304].includes(res.status) ? null : await res.text()
const data = body ? JSON.parse(body) : {}
return { data, status: res.status, headers: res.headers } // ← 500 도 그냥 반환
}
status 와 무관하게 resolve 한다. status 를 반환값에 담아주니 “호출하는 쪽이 알아서 판단하라”는 설계다.
문제는 react-query 의 규약과 안 맞는다는 것이다. react-query 는 queryFn 이 throw 해야 에러로 인식한다. 위 코드는 절대 throw 하지 않으므로 모든 응답이 성공 처리된다.
axios 클라이언트를 쓰던 사람이 fetch 로 옮기면 이 차이를 밟는다. axios 는 non-2xx 에서 reject 하기 때문이다.
해결 — custom mutator
orval 설정에 mutator 를 지정한다.
// orval.config.ts
output: {
client: 'react-query',
httpClient: 'fetch',
baseUrl: '/api',
override: {
mutator: { path: './src/shared/api/fetch-client.ts', name: 'customFetch' },
},
}
mutator 는 생성된 응답 계약을 그대로 유지하면서 !res.ok 일 때 throw 해야 한다.
// src/shared/api/fetch-client.ts
import { ApiError } from './errors'
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null
}
export const customFetch = async <T>(url: string, options: RequestInit): Promise<T> => {
const res = await fetch(url, options) // url 에 이미 baseUrl 적용됨 → prepend 금지
const raw = [204,205,304].includes(res.status) ? null : await res.text()
const data: unknown = raw ? JSON.parse(raw) : {}
if (!res.ok) {
const message = isRecord(data) && typeof data.message === 'string' ? data.message : res.statusText
const code = isRecord(data) && typeof data.code === 'string' ? data.code : undefined
throw new ApiError(message, { status: res.status, code })
}
return { data, status: res.status, headers: res.headers } as T // 생성 계약 유지
}
생성 후 호출부가 이렇게 바뀐다.
return customFetch<listUsersResponse>(getUrl(), { ... })
밟기 쉬운 곳 두 군데
mutator 가 body 만 반환하면 안 된다
“어차피 data 만 쓰는데” 싶어서 return data 로 끝내면 런타임과 타입이 어긋난다.
생성된 타입은 listUsersResponse = { data, status, headers } 다. body 만 반환하면 소비 측의 .data 와 .status 가 전부 undefined 가 된다. 타입은 통과하는데 런타임만 깨지므로 발견이 늦다.
wrapper shape 를 유지해야 한다.
baseUrl 을 두 번 붙이지 않는다
output.baseUrl 을 설정하면 생성된 getXxxUrl() 이 이미 /api/... 를 포함한다. mutator 에서 다시 붙이면 /api/api/users 가 된다.
mutator 는 받은 url 을 그대로 fetch 한다. 그게 규약이다.
검증
수동 확인은 놓치기 쉬우므로 테스트로 고정했다. MSW 로 500 핸들러를 override 하고 두 가지를 본다.
expect(result.current.isError).toBe(true)
expect(result.current.error).toBeInstanceOf(ApiError)
isError 만 보면 부족하다. throw 는 하는데 엉뚱한 에러 타입이면 상위의 에러 분기가 또 안 먹는다.
정리
codegen 결과물을 쓸 때 확인할 게 하나 늘었다. 생성된 클라이언트가 어떤 조건에서 reject 하는가. 이게 소비하는 라이브러리(여기서는 react-query)의 에러 규약과 맞는지 봐야 한다.
두 규약이 어긋나면 조용히 성공 경로로 흐른다. 에러가 안 나는 게 아니라 에러를 못 보는 상태라서, 프로덕션에서 빈 화면이나 이상한 기본값으로 나타난다.
확인한 조합은 orval 8.x + @tanstack/react-query + msw 2.x 다.