- STRIPEtype · code · param
- GRAPHQLerrors[] · extensions
- REST500 · <html>
클라이언트가 무엇이든, 벤더가 무엇이든, 밖으로 나올 땐 타입 하나다 — NormalizedError. 위 그림 왼쪽이 무엇이든 오른쪽은 늘 같은 모양이라는 게 이 라이브러리의 거의 전부다.
에러의 모양을 두 축으로 갈랐다. 하나는 닫힌 kind — network·timeout·canceled·http·validation·domain 여섯 종. 무엇이 실패했는지에 대한 거친 라우팅 힌트다. 다른 하나는 열린 code — 자유 문자열이고, 실패의 정체성이다. 카드가 거절됐는지 레이트리밋에 걸렸는지는 여기서 읽는다. Stripe가 type(범주)와 code(구체)를 나눠 쓰는 방식에서 가져왔다.
kind를 닫아둔 건 소비자가 switch (kind)로 분기할 때 컴파일러가 빠짐없이 챙겨주게 하려는 것이고, code를 열어둔 건 벤더마다 코드 체계가 다른 데다 그걸 미리 다 알 수 없기 때문이다. 앞의 세 종 — network·timeout·canceled — 은 응답이 도착하기 전에 실패한 경우라 어떤 HTTP 상태로도 표현되지 않는다. fetch 클라이언트들이 HTTPError와 별개로 TimeoutError·AbortError를 따로 두는 이유와 같다.
확장은 매퍼 체인 하나로 연다. 매퍼는 (raw, ctx) => NormalizedError | null 함수고, 순서대로 시도해 처음으로 null이 아닌 값을 낸 매퍼가 이긴다. 아무도 잡지 않으면 내장 fallback이 전송 계층 실패 — 중단·타임아웃·네트워크 — 를, 응답 상태를 알 땐 HTTP 에러를 분류한다.
export function normalizeError(raw: unknown, options: NormalizeOptions = {}): NormalizedError {
const existing = asNormalizedError(raw)
if (existing) return existing // 이미 정규화된 에러는 그대로 통과
const context = options.context ?? {}
for (const mapper of options.mappers ?? []) {
const mapped = mapper(raw, context)
if (mapped) return asNormalizedError(mapped)
}
return fallback(raw, context) // 아무도 안 잡으면 내장 분류로
}
매퍼가 지켜야 할 계약은 딱 하나다 — 던지지 않는다. 사양할 땐 null을 돌려주고, 잡을 땐 NormalizedError를 돌려준다. 그 한 줄이 나머지 설계를 거의 다 정했다. 로깅 경로 한복판에서 정규화가 터지면, 정작 남기려던 에러가 사라지기 때문이다.
표준을 인용한다는 것
필드를 새로 발명하는 건 쉽다. 어려운 건 그게 왜 그 이름이고 그 의미인지 설명하는 일이다. 그래서 가능한 곳마다 인용 가능한 표준에 붙였다. 재시도까지 기다릴 시간은 Retry-After(RFC 9110)를 밀리초로 옮긴 retryAfterMs로, 벤더가 표준 형식으로 답한 본문은 원형 그대로 problem에, 원본 에러는 ES2022 Error.cause에 담는다. 어느 것도 내가 지어낸 이름이 아니다. "왜 이 필드가 이 이름이냐"는 물음에 스펙으로 답할 수 있다는 게, 임의로 정한 규칙과의 차이다.
표준이 아닌 것은 아닌 대로 정직하게 뒀다. fetch는 네트워크 실패를 TypeError로 던지는데, 그 메시지 문구는 런타임마다 다르다 — 크로뮴은 failed to fetch, 사파리는 load failed, Node는 fetch failed. 알려진 문구들을 모아 매칭하되, 코드 주석에 분명히 남겼다: 이건 표준이 아니라 엔진 내부 구현이고 버전이 바뀌면 흔들릴 수 있다고. "스펙에 근거한 것"과 "관례라서 그런 것"은 신뢰의 무게가 다르니까.
세 벤더, 한 모양
이제 실제로 벤더를 붙인다. 프리빌트 매퍼는 셋 — 표준 하나(rfc9457)와 실제 벤더 둘(stripe·graphql).
- rfc9457 —
application/problem+json본문을 모양으로 알아본다.content-type헤더가 아니라type·title·detail중 하나라도 있는지를 본다. 많은 API가 problem 모양의 본문을 그냥application/json으로 흘려보내기 때문이다. - stripe —
{ error: { type, code, message } }봉투.code(card_declined등)를 정체성으로 쓰고, 없으면 더 넓은type으로 떨어진다.decline_code·param같은 고유 필드는 일부러 승격하지 않았다 — 필요하면cause에서 각자의 Stripe 타입으로 읽으면 된다. - graphql —
{ errors: [...] }. 스펙 §7을 직접 대조하며 만들었다. GraphQL은 실패해도 HTTP 200으로 오는 경우가 많아서, 상태가 없으면domain으로 분류하고code는 관례적 위치인 첫 에러의extensions.code에서 꺼낸다.
셋을 관통하는 규칙은 하나다. 추측으로 매핑하지 않는다. 승격하는 필드는 스펙에 있는 것만이고, 나머지는 전부 cause에 원본 그대로 남는다.
import { normalizeError, stripeMapper, graphqlMapper } from '@zerovoids/http-core'
const err = normalizeError(raw, { mappers: [stripeMapper, graphqlMapper] })
// ^? NormalizedError — 어느 벤더로 들어왔든 여기서부터는 한 모양
여기까지가 라이브러리가 실제로 하는 일이다. 남은 건, 이게 정말 쓸 만한 물건인가에 대한 정직한 회고다.