Split View: 에러 처리 완전 가이드: 실패를 계약으로 설계하기
에러 처리 완전 가이드: 실패를 계약으로 설계하기
- 들어가며
- 1. 오류를 분류하는 두 축
- 2. 예외와 반환값 — 승자 없는 논쟁
- 3. 경계에서 오류를 번역하기
- 4. HTTP 오류 계약: 상태 코드와 problem details
- 5. 재시도 — 멱등성 없이는 재시도도 없다
- 6. 부분 실패와 타임아웃 예산
- 7. 관측 — 로그·메트릭·트레이스에서의 오류
- 8. 사용자에게 무엇을 말할 것인가
- 9. 안티패턴 목록
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
대부분의 코드베이스에서 성공 경로는 설계되고 실패 경로는 발생합니다. 성공 응답의 필드 이름은 리뷰에서 논쟁거리가 되지만, 오류 응답은 "일단 500으로 던지고 나중에 정리하자"로 넘어갑니다. 그 "나중에"는 대개 장애 회고 자리입니다.
이 블로그에는 CORS 에러, 서버를 고쳐야 하는 이유처럼 특정 오류를 해부한 글과, SLI/SLO/Error Budget 기반 신뢰성 엔지니어링처럼 오류를 예산으로 다루는 글이 있습니다. 하지만 애플리케이션 오류 설계 자체를 다룬 글은 없습니다. 이 글이 그 자리를 채웁니다. 실패를 예외 문법의 문제가 아니라 인터페이스 계약의 일부로 놓고, 오류가 계층 경계를 넘을 때마다 무엇을 번역하고 무엇을 버리는지를 따라갑니다.
근거는 RFC 9110, RFC 6585, RFC 9457, 구글 SRE 책의 캐스케이딩 실패 장, 그리고 OWASP 인증 치트시트입니다.
1. 오류를 분류하는 두 축
에러 처리 설계는 분류에서 시작합니다. 분류가 없으면 모든 오류가 catch (Exception e) 한 곳으로 모이고, 그 지점에서는 어떤 판단도 내릴 수 없습니다.
1-1. 축 1 — 예상된 실패인가 버그인가
- 예상된 실패: 도메인 규칙상 정상적으로 일어날 수 있는 결과입니다. 잔액 부족, 중복 예약, 만료된 쿠폰. 이것은 값이며, 함수 시그니처에 나타나야 합니다.
- 버그: 코드의 불변식이 깨진 상태입니다. null 역참조, 배열 범위 초과, 도달 불가능한 분기. 이것은 값이 아니라 결함이며, 삼키면 안 됩니다.
이 구분을 흐리면 두 가지 사고가 동시에 납니다. 버그를 도메인 오류처럼 사용자에게 보여 주게 되고, 도메인 오류를 500으로 보고해 알림 피로를 만듭니다.
1-2. 축 2 — 재시도 가능한가
- 재시도 가능: 같은 요청을 잠시 뒤 다시 보내면 성공할 수 있습니다. 일시적 네트워크 오류, 타임아웃, 429, 503.
- 재시도 불가: 몇 번을 보내도 결과가 같습니다. 400, 401, 403, 404, 422, 대부분의 409.
1-3. 네 사분면과 처리 방침
| 재시도 가능 | 재시도 불가 | |
|---|---|---|
| 예상된 실패 | 백오프 후 재시도, 실패 시 사용자에게 안내 | 사용자에게 교정 방법을 알려 주고 종료 |
| 버그 | 존재하지 않음 (있다면 분류가 틀린 것) | 로깅·알림, 사용자에게는 일반 메시지 |
"예상된 실패이면서 재시도 가능"이 가장 많은 코드를 필요로 하는 칸입니다. 여기가 5절과 6절의 주제입니다.
1-4. 세 번째 축 — 누구의 잘못인가
클라이언트, 이쪽 서버, 하위 의존성. 이 축은 4절의 HTTP 상태 코드 선택으로 그대로 이어집니다. 하위 의존성의 실패를 4xx로 보고하면 클라이언트가 고칠 수 없는 것을 고치라고 요구하는 셈이 됩니다.
2. 예외와 반환값 — 승자 없는 논쟁
여기는 언어 공동체마다 답이 다르고, 어느 쪽도 상대를 설득하지 못한 자리입니다. 승자를 정하는 대신 축을 봅니다.
예외 방식 반환값 방식
호출자가 잊어도 컴파일됨 호출자가 처리를 잊기 어려움
시그니처에 안 보일 수 있음 시그니처에 실패가 드러남
중간 계층이 코드를 안 씀 중간 계층마다 전파 코드가 필요
원거리에서 한 번에 처리 호출 지점에서 지역적으로 처리
2-1. 축 다섯 가지
- 강제성: 호출자가 실패를 무시할 수 있는가. 반환값 방식은 대개 무시하기 어렵고, 검사 없는 예외는 쉽게 잊힙니다.
- 시그니처 가시성: 함수만 보고 어떤 실패가 가능한지 알 수 있는가.
- 전파 비용: 오류를 위로 올리는 데 필요한 보일러플레이트의 양. 예외는 여기서 압도적으로 짧습니다.
- 처리 위치: 실패를 발생 지점 근처에서 다루고 싶은가, 한참 위에서 모아 다루고 싶은가.
- 정보 보존: 전파 과정에서 원인 체인과 문맥이 얼마나 남는가.
2-2. 각 접근이 지불하는 값
예외 중심 언어는 전파 비용이 거의 0인 대신, 어떤 실패가 어디서 나오는지 시그니처만 봐서는 알 수 없다는 값을 치릅니다. 검사 예외는 그것을 시그니처로 끌어올리는 대신, 인터페이스가 구현 세부에 오염되고 개발자가 빈 catch로 우회하는 경향을 만듭니다. 반환값 중심 언어는 실패를 타입으로 드러내는 대신, 계층마다 전파 코드를 쓰게 되고 그 과정에서 원인 문맥을 덧붙이는 규율이 필요합니다.
실무적 결론은 하나뿐입니다. 팀이 쓰는 언어의 관용을 따르는 것이 가장 싸다는 것입니다. 언어 관용을 거스르는 선택은 라이브러리 생태계, 정적 분석 도구, 신규 입사자의 기대와 전부 싸우게 됩니다.
2-3. 방식과 무관하게 공통인 규칙
- 실패는 시그니처나 문서 어느 한쪽에는 반드시 드러나야 합니다.
- 오류를 삼키지 않습니다. 빈 catch는 정보를 지우는 코드입니다.
- 오류를 문자열 메시지로만 표현하지 않습니다. 분기 가능한 타입이나 코드가 필요합니다.
- 흐름 제어에 예외를 쓰지 않습니다. 반복문 종료를 예외로 하는 코드는 읽는 사람과 프로파일러를 동시에 속입니다.
3. 경계에서 오류를 번역하기
오류 설계의 대부분은 경계에서 무엇을 번역하고 무엇을 버릴지 정하는 일입니다.
외부 API 클라이언트 ─┐
├─▶ 도메인 오류 ─▶ HTTP 오류 계약 ─▶ 사용자 문구
데이터 저장소 ───────┘ │
└─▶ 이벤트·큐 재처리 정책
3-1. 경계별 번역 규칙
| 경계 | 들어오는 것 | 나가는 것 | 보존할 것 |
|---|---|---|---|
| 저장소 → 도메인 | 제약 위반, 커넥션 오류 | 도메인 오류, 인프라 오류 | 원인 체인, 재시도 가능 여부 |
| 외부 API → 도메인 | 상태 코드, 타임아웃 | 도메인 오류 | 상대 서비스 이름, 상관 ID |
| 도메인 → HTTP | 도메인 오류 | 상태 코드 + problem details | 오류 유형 식별자 |
| 도메인 → 큐 | 도메인 오류 | 재시도, 지연 재시도, 데드레터 | 시도 횟수, 마지막 원인 |
3-2. 두 가지 안티패턴
계층 누수가 첫 번째입니다. 저장소의 제약 위반 예외가 컨트롤러까지 그대로 올라오면, 컨트롤러가 저장소 기술을 알아야 하고 저장소를 바꾸는 순간 컨트롤러가 깨집니다.
과도한 래핑이 두 번째입니다. 계층마다 새 예외로 감싸면서 원인을 버리면, 로그에는 "처리 중 오류가 발생했습니다"만 다섯 겹으로 쌓이고 진짜 원인은 사라집니다. 규칙은 간단합니다. 감쌀 때는 반드시 원인을 붙여서 감쌉니다.
3-3. 재시도 가능 플래그를 경계 너머로 옮기기
하위 계층만 아는 정보가 하나 있습니다. 그 실패가 일시적인지 여부입니다. 이 정보를 도메인 오류에 실어 올리지 않으면, 상위 계층은 메시지 문자열을 보고 추측하게 됩니다. 도메인 오류 타입에 재시도 가능 여부를 명시적 속성으로 넣는 것이, 이 글에서 권하는 가장 값싼 개선 중 하나입니다.
4. HTTP 오류 계약: 상태 코드와 problem details
4-1. 상태 코드는 계층 전체가 읽는 값
RFC 9110의 정의를 기준으로 자주 헷갈리는 것을 정리합니다. 400은 클라이언트 오류로 서버가 처리할 수 없는 경우, 401은 "대상 리소스에 대한 유효한 인증 자격 증명이 요청에 없음", 403은 "서버가 요청을 이해했지만 이행을 거부함", 409는 "요청이 대상 리소스의 현재 상태와 충돌함", 422는 콘텐츠 타입과 문법은 이해했지만 지시를 처리할 수 없는 경우입니다. RFC 9110은 §15.5.2에서 401을, §15.5.4에서 403을 정의합니다. 401은 인증, 403은 인가입니다.
429 Too Many Requests는 RFC 9110이 아니라 RFC 6585에 있습니다. 원문은 "사용자가 주어진 시간 동안 너무 많은 요청을 보냈다(레이트 리미팅)"는 뜻이라고 하고, 응답에 Retry-After 헤더를 "포함할 수 있다(MAY)"고 씁니다.
4-2. 본문은 RFC 9457로
RFC 9457은 RFC 7807을 대체하며 application/problem+json 미디어 타입을 정의합니다. §3.1의 멤버는 type(URI 참조, 문제 유형의 주 식별자, 없으면 about:blank), status(조언용, 실제 상태 코드와 일치), title(지역화를 빼면 발생 건마다 바뀌면 안 됨), detail(이번 발생의 설명), instance(이번 발생을 식별하는 URI)입니다.
detail에 대해 스펙은 "디버깅 정보를 주기보다 클라이언트가 문제를 바로잡도록 돕는 데 초점을 맞춰야" 한다고 씁니다. 이 한 문장이 오류 메시지 작성의 기준입니다.
예시 — 재시도 가능 여부까지 담은 오류 응답.
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 30
{
"type": "https://api.example.com/problems/upstream-unavailable",
"title": "Upstream service unavailable",
"status": 503,
"detail": "결제 승인 서비스에 일시적으로 연결할 수 없습니다. 30초 후 같은 요청을 다시 보내세요.",
"instance": "/v1/payments/req_01J9XQ",
"retryable": true,
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
§3.2는 확장 멤버를 허용하고 클라이언트가 "인식하지 못하는 확장을 반드시 무시해야 한다(MUST)"고 규정하므로, retryable 과 traceId 같은 필드를 나중에 추가해도 파괴적 변경이 아닙니다.
4-3. 오류 유형 식별자를 클라이언트 분기의 유일한 근거로
클라이언트가 분기해야 하는 것은 type 값이지 detail 문자열이 아닙니다. 이 규칙을 문서에 명시하지 않으면 클라이언트가 메시지를 정규식으로 매칭하기 시작하고, 그 순간 문구 수정이 배포를 요구하게 됩니다.
5. 재시도 — 멱등성 없이는 재시도도 없다
5-1. 먼저 멱등성
RFC 9110 §9.2.2는 idempotent를 "여러 번의 동일한 요청이 서버에 의도한 효과가 한 번의 요청과 같다"고 정의하고 GET, HEAD, PUT, DELETE, OPTIONS, TRACE를 여기에 포함합니다. POST는 멱등하지 않습니다. 따라서 POST를 재시도하려면 멱등성 키가 필요하고, 키가 없다면 그 요청은 재시도하면 안 되는 요청입니다. 이 판단이 코드에 명시적으로 존재하지 않는 시스템에서는, 재시도가 조용히 중복 결제를 만듭니다.
5-2. 백오프는 반드시 무작위화한다
구글 SRE 책은 재시도를 스케줄할 때 "항상 무작위화된 지수 백오프를 사용하라"고 못 박습니다. 이유는 명확합니다. 고정 간격이나 순수 지수 백오프는 실패 시점이 같은 클라이언트들을 같은 시각에 다시 몰려오게 만들고, 이것이 회복 중인 서버를 다시 쓰러뜨립니다.
시도 1 실패 → 대기 = random(0, 1초)
시도 2 실패 → 대기 = random(0, 2초)
시도 3 실패 → 대기 = random(0, 4초)
상한 도달 → 포기하고 오류를 위로 전달
5-3. 재시도 예산과 상한
같은 문서는 프로세스 단위의 재시도 예산을 권합니다. 예로 든 값은 "프로세스에서 분당 60회의 재시도만 허용"입니다. 그리고 "특정 요청을 무한히 재시도하지 말라"고 명시합니다. 예산이 소진되면 재시도를 멈추고 오류를 그대로 위로 올립니다. 예산이 없는 재시도는 부하가 걸린 순간에 부하를 배로 만드는 장치입니다.
5-4. 다층 재시도가 곱해진다
가장 자주 놓치는 항목입니다. 구글 SRE 책은 세 계층이 각각 4회씩 재시도하면 한 번의 사용자 동작이 4 × 4 × 4 = 64회의 시도가 된다고 지적합니다. 클라이언트 SDK, API 게이트웨이, 서비스 간 클라이언트가 각각 "합리적인" 재시도를 켜 두면 이 상황이 만들어집니다.
대응은 재시도 계층을 하나로 정하는 것입니다. 대개 사용자에게 가장 가까운 한 계층에서만 재시도하고, 나머지 계층은 실패를 즉시 전달합니다. 그리고 재시도 여부를 로그와 트레이스에 표시해 실제 시도 횟수를 관측 가능하게 만듭니다. 재시도 횟수와 성공 확률의 관계는 재시도·누적 확률 계산기로 감을 잡을 수 있습니다.
5-5. 무엇을 재시도하고 무엇을 하지 않는가
- 재시도한다: 연결 실패, 타임아웃(멱등일 때), 429(
Retry-After존중), 503, 일부 500 - 재시도하지 않는다: 400, 401, 403, 404, 422, 대부분의 409
구글 SRE 책은 재시도 가능한 오류와 불가능한 오류를 명확한 코드로 구분하고 영구적 오류는 절대 재시도하지 말라고 말합니다. 400을 재시도하는 클라이언트는 자기 버그를 서버 부하로 바꿉니다.
5-6. 거절도 전략이다
같은 장은 부하 차단을 다룹니다. 무한정 큐잉하는 대신 일찍 거절하라는 것입니다. 503으로 빠르게 거절하면 클라이언트는 백오프하고 서버는 살아 있는 요청을 처리합니다. 큐에 쌓아 두면 큐 대기 시간이 클라이언트 타임아웃을 넘겨, 아무도 받지 않을 응답을 만드느라 자원을 태우게 됩니다. 회로를 여는 판단은 서킷 브레이커 패턴 완벽 가이드에서 더 다룹니다.
6. 부분 실패와 타임아웃 예산
6-1. 타임아웃은 예산이다
사용자 대면 요청에 3초의 마감이 있다면, 그 3초는 하위 호출들이 나눠 쓰는 예산입니다. 각 호출에 개별 타임아웃을 따로 정하면 합이 마감을 넘고, 그러면 사용자는 이미 떠났는데 서버는 계속 일합니다.
사용자 마감 3000ms
├─ 인증 확인 150ms
├─ 주문 조회 400ms
├─ 결제 승인 1500ms (재시도 1회 포함 → 실제 상한 2 × 700ms)
└─ 여유 950ms (직렬화, GC, 네트워크 변동)
남은 시간을 하위 호출로 전달하면 이 예산이 실제로 지켜집니다. 상위에서 이미 2초를 쓴 뒤 호출되는 하위 서비스는 1초짜리 마감을 받아야 합니다. 이 전파가 없으면 각 서비스가 자기 기준으로 최대치를 기다립니다.
6-2. 타임아웃과 재시도는 곱해진다
타임아웃 1초에 재시도 3회면 최악의 경우 3초입니다. 여기에 상위 계층이 다시 재시도하면 5-4절의 곱셈이 일어납니다. 타임아웃 예산은 재시도를 포함해서 계산해야 합니다.
6-3. 부분 실패의 응답 설계
다건 요청에서 일부만 실패했을 때의 계약을 미리 정합니다. 선택지는 셋입니다.
- 전부 실패로 처리: 단순하지만 성공한 작업을 되돌릴 수 있어야 합니다.
- 항목별 상태 배열 반환: 각 항목에 성공 여부와 오류 유형을 실어 보냅니다. 클라이언트가 실패한 것만 재시도할 수 있습니다.
- 작업 리소스로 비동기화: 요청을 접수하고 진행 상태를 조회하게 합니다.
두 번째가 가장 널리 쓰이지만, 전체 요청의 상태 코드를 무엇으로 할지를 반드시 문서화해야 합니다. 이것을 정하지 않으면 클라이언트마다 다르게 해석합니다.
6-4. 캐스케이딩 실패
구글 SRE 책은 캐스케이딩 실패를 "양의 피드백의 결과로 시간이 지나면서 커지는 실패"라고 정의합니다. 한 클러스터가 죽어 트래픽이 다른 클러스터로 몰리는 서버 과부하, 그리고 CPU·메모리·스레드·파일 디스크립터의 자원 고갈이 대표적인 원인이며, 자원 고갈은 서로를 악화시킵니다. 스레드가 모자라면 지연이 늘고, 지연이 늘면 대기 중인 요청이 메모리를 더 붙잡습니다.
에러 처리 관점에서의 교훈은 하나입니다. 실패한 요청이 자원을 오래 붙잡고 있게 두지 않는 것입니다. 타임아웃 없는 호출, 무한 큐, 무한 재시도는 전부 같은 실패를 만듭니다.
7. 관측 — 로그·메트릭·트레이스에서의 오류
7-1. 오류 하나에 로그 하나
가장 흔한 낭비는 계층마다 로깅하는 것입니다. 다섯 계층이 각각 로깅하면 오류 하나가 로그 다섯 줄이 되고, 오류율 계산과 알림이 전부 부풀려집니다. 규칙은 처리하는 곳에서 한 번만 로깅하는 것입니다. 중간 계층은 로깅 대신 문맥을 붙여 전파합니다.
예시 — 오류 로그에 반드시 들어갈 필드.
{
"level": "error",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"errorType": "upstream_unavailable",
"retryable": true,
"attempt": 2,
"upstream": "payment-gateway",
"durationMs": 1043,
"message": "payment authorization failed after retry"
}
7-2. 메트릭은 분모가 중요하다
오류 수만 세면 트래픽이 늘 때 자동으로 늘어납니다. 필요한 것은 오류율이고, 분모가 무엇인지가 계약입니다. 재시도 끝에 성공한 요청을 성공으로 셀지 실패로 셀지에 따라 숫자가 크게 달라지므로, 사용자 관점 성공률과 시도 단위 성공률을 둘 다 계측하는 편이 낫습니다. 오류율을 SLO로 묶는 방법은 SLI/SLO/Error Budget 기반 신뢰성 엔지니어링에서 다룹니다.
7-3. 카디널리티 폭발을 피한다
오류 메시지 문자열을 메트릭 라벨로 쓰면 시계열이 폭발합니다. 라벨에는 1절의 분류에서 나온 유한한 집합만 넣습니다. 오류 유형, 재시도 가능 여부, 상대 서비스 이름 정도입니다. 자유 문자열은 로그에 남기고 메트릭에는 넣지 않습니다.
7-4. 알림은 오류가 아니라 소진율에
오류 하나에 알림을 거는 시스템은 곧 무시됩니다. 알림은 오류 자체가 아니라 오류 예산의 소진 속도에 걸어야 합니다. 그리고 알림 본문에 상관 ID와 오류 유형을 넣어, 알림에서 트레이스로 한 번에 이동할 수 있게 합니다.
8. 사용자에게 무엇을 말할 것인가
8-1. 보안 경계
RFC 9457 §5는 문제 상세에 담기는 정보가 "신중하게 검토되어야" 하며 "스택 덤프 같은 구현 세부 정보"를 노출하지 말라고 명시합니다. 스택 트레이스, 내부 호스트명, SQL 문, 라이브러리 버전은 전부 공격자에게 유용한 정보입니다.
인증 실패는 특히 조심할 자리입니다. OWASP 인증 치트시트는 애플리케이션이 "HTTP와 HTML 양쪽에서 일반적인(generic) 방식으로 응답"해야 한다고 말하며, "로그인 실패: 잘못된 사용자 ID 또는 비밀번호" 같은 문구를 예로 듭니다. 사용자명 열거를 막기 위해서입니다. 같은 문서는 "HTTP 응답 코드가 달라지는 것만으로도 계정의 유효 여부가 새어 나갈 수 있다"고 덧붙입니다. 문구만 통일하고 상태 코드나 응답 시간이 갈리면 방어가 되지 않습니다.
8-2. 사용자 메시지의 세 요소
- 무슨 일이 있었는가: 기술 용어 없이 한 문장
- 무엇을 할 수 있는가: 재시도, 값 수정, 대기, 문의 중 하나
- 문의용 식별자: 상관 ID. 이것이 없으면 지원 팀이 로그에서 사건을 찾지 못합니다
8-3. 대기 시간을 알려 주기
재시도 가능한 오류라면 얼마나 기다려야 하는지를 알려 줍니다. RFC 6585는 429 응답이 Retry-After 헤더를 포함할 수 있다고(MAY) 하고, 503에도 같은 헤더를 쓸 수 있습니다. UI는 이 값을 읽어 "30초 후 자동으로 다시 시도합니다"로 바꿔 보여 주면 됩니다. 이 하나로 사용자의 수동 새로고침이 크게 줄고, 그만큼 서버 부하도 줄어듭니다.
8-4. 기계용 값과 사람용 문장을 분리한다
type은 기계용, title과 detail은 사람용입니다. 스펙이 title을 발생 건마다 바꾸지 말라고 하는 이유도 여기에 있습니다. 사람용 문장은 지역화와 문구 개선으로 자주 바뀌어야 하고, 기계용 값은 절대 바뀌면 안 됩니다.
9. 안티패턴 목록
- 빈 catch: 오류를 지우는 코드입니다. 최소한 로깅하거나, 의도적 무시라면 이유를 주석이 아니라 코드로 표현합니다.
- 모든 오류를 500으로: 클라이언트가 고칠 수 있는 오류와 고칠 수 없는 오류가 섞여, 재시도 로직과 알림이 동시에 망가집니다.
- 200에 오류 담기: 프록시·모니터링·재시도 미들웨어 전부가 성공으로 읽습니다.
- 오류를 null로 반환: 호출자는 "값이 없음"과 "실패"를 구분하지 못하고, 결국 null 검사와 예외 처리가 뒤섞입니다.
- 문자열 매칭으로 분기: 오류 메시지가 계약이 되어 문구 수정이 장애가 됩니다.
- 타임아웃 없는 호출: 하나의 느린 의존성이 전체 스레드 풀을 잠급니다.
- 다층 재시도: 4 × 4 × 4 = 64회 문제. 재시도 계층을 하나로 정합니다.
- 계층마다 로깅: 오류율이 부풀려지고 알림이 신뢰를 잃습니다.
- 원인을 버리는 래핑: 감쌀 때는 반드시 원인 체인을 붙입니다.
- 흐름 제어에 예외 사용: 성능과 가독성을 동시에 잃습니다.
- 사용자에게 스택 트레이스 노출: RFC 9457 §5가 명시적으로 금지하는 패턴입니다.
- 재시도 예산 없음: 부하가 걸린 순간 부하를 배로 만듭니다.
퀴즈: 실력을 확인해 보세요
퀴즈 1: 결제 요청이 타임아웃됐습니다. 클라이언트가 자동으로 재시도해도 될까요?
정답: 요청이 멱등성 키를 포함하고 서버가 그 키를 지원할 때만 안전합니다. 그렇지 않다면 재시도하면 안 됩니다.
설명: 타임아웃은 요청이 서버에 도달했는지조차 알려 주지 않습니다. RFC 9110 §9.2.2가 정의하는 멱등성은 GET, HEAD, PUT, DELETE, OPTIONS, TRACE에 적용되고 POST에는 적용되지 않으므로, POST 결제 요청의 재시도는 중복 승인을 만들 수 있습니다. 멱등성 키가 있다면 서버가 같은 키의 두 번째 요청을 첫 번째의 결과로 응답하므로 안전해집니다. 재시도 가능 여부를 오류 응답에 명시적으로 실어 보내면 클라이언트가 추측하지 않아도 됩니다.
퀴즈 2: 장애 중 오류율 대시보드가 평소의 다섯 배로 튀었습니다. 실제 실패 요청 수는 그 정도가 아니었습니다. 무엇을 먼저 의심할까요?
정답: 계층마다 로깅하고 있거나, 재시도 시도가 각각 오류로 집계되고 있을 가능성입니다.
설명: 오류 하나가 다섯 계층에서 각각 로깅되면 지표는 다섯 배가 됩니다. 재시도도 같습니다. 시도 단위로 세면 3회 재시도가 3건의 오류가 되지만, 사용자 관점에서는 실패 1건입니다. 대응은 처리하는 곳에서 한 번만 로깅하고, 사용자 관점 성공률과 시도 단위 성공률을 분리해 계측하는 것입니다. 두 숫자가 모두 필요하며, 하나만 보면 각각 다른 방향으로 오판하게 됩니다.
퀴즈 3: 클라이언트 SDK, 게이트웨이, 서비스 간 클라이언트가 각각 4회 재시도로 설정되어 있습니다. 무엇이 문제인가요?
정답: 재시도가 곱해져 사용자 동작 한 번이 최대 4 × 4 × 4 = 64회의 요청이 됩니다.
설명: 구글 SRE 책이 캐스케이딩 실패를 다루며 지적하는 대표적인 증폭 경로입니다. 하위 서비스가 이미 부하로 느려진 상태에서 이 증폭이 걸리면, 회복 중인 서비스를 다시 쓰러뜨립니다. 대응은 재시도 계층을 하나로 정하고 나머지는 실패를 즉시 전달하게 하는 것, 그리고 프로세스 단위 재시도 예산을 두는 것입니다. 같은 문서는 예로 프로세스에서 분당 60회 재시도 제한을 들고, 특정 요청을 무한히 재시도하지 말라고 명시합니다.
퀴즈 4: 로그인 실패 시 "존재하지 않는 아이디입니다"와 "비밀번호가 틀렸습니다"를 구분해 보여 주고 있습니다. 어떤 위험이 있나요?
정답: 사용자명 열거가 가능해집니다. 공격자가 어떤 계정이 실제로 존재하는지 확인할 수 있습니다.
설명: OWASP 인증 치트시트는 애플리케이션이 HTTP와 HTML 양쪽에서 일반적인 방식으로 응답해야 한다고 말하며 "로그인 실패: 잘못된 사용자 ID 또는 비밀번호" 같은 문구를 예로 듭니다. 주의할 점은 문구만 통일해서는 부족하다는 것입니다. 같은 문서는 HTTP 응답 코드가 달라지는 것만으로도 계정의 유효 여부가 새어 나갈 수 있다고 지적합니다. 응답 시간 차이도 같은 정보를 흘리므로 함께 맞춰야 합니다.
퀴즈 5: 사용자 대면 요청의 마감이 3초인데, 하위 호출 네 개에 각각 2초 타임아웃이 걸려 있습니다. 무엇이 잘못됐나요?
정답: 개별 타임아웃의 합이 마감을 크게 넘습니다. 타임아웃은 개별 값이 아니라 마감에서 나눠 쓰는 예산이어야 합니다.
설명: 최악의 경우 8초가 걸리고, 그동안 사용자는 이미 떠났는데 서버는 계속 자원을 붙잡고 일합니다. 이것이 캐스케이딩 실패의 자원 고갈 경로입니다. 대응은 마감에서 역산해 각 호출의 예산을 배분하고, 남은 시간을 하위 호출로 전파하는 것입니다. 그리고 재시도가 있다면 그 곱까지 예산 안에 포함해 계산해야 합니다.
퀴즈 6: 오류 응답의 detail 문구를 다듬었더니 모바일 앱에서 특정 화면이 동작하지 않게 됐습니다. 근본 원인은 무엇일까요?
정답: 클라이언트가 사람용 문장을 분기 조건으로 쓰고 있었습니다. 기계용 값과 사람용 문장이 분리되지 않은 것입니다.
설명: RFC 9457은 type을 문제 유형의 주 식별자로 두고, detail은 이번 발생에 대한 설명이며 "클라이언트가 문제를 바로잡도록 돕는 데 초점을 맞춰야" 한다고 규정합니다. 즉 detail은 사람이 읽는 문장이며 언제든 바뀔 수 있습니다. 클라이언트가 분기해야 하는 값은 type이고, 이 규칙을 API 문서에 명시해야 합니다. 명시하지 않으면 문구 개선과 지역화가 전부 파괴적 변경이 됩니다.
마치며
에러 처리는 문법의 문제가 아니라 계약의 문제입니다. 어떤 실패가 가능한지, 그 실패가 재시도 가능한지, 경계를 넘을 때 무엇이 보존되는지, 사용자에게 무엇을 말할지가 전부 계약이며, 계약이므로 문서에 적혀야 하고 바뀌면 파괴적 변경입니다.
가장 값싼 개선 세 가지를 꼽으면 이렇습니다. 첫째, 도메인 오류 타입에 재시도 가능 여부를 명시적 속성으로 넣습니다. 상위 계층이 문자열을 보고 추측하지 않게 됩니다. 둘째, 재시도 계층을 하나로 정하고 나머지 계층은 실패를 즉시 전달합니다. 4 × 4 × 4 문제가 사라집니다. 셋째, 오류 응답을 RFC 9457 형식으로 통일하고 클라이언트는 type으로만 분기한다를 문서에 못 박습니다. 문구를 고칠 자유가 생깁니다.
세 가지 모두 새 라이브러리가 필요 없고, 셋 다 장애 한 번의 비용보다 훨씬 쌉니다.
참고 자료
- RFC 9110 — HTTP Semantics — §9.2.2의 idempotent 정의와 대상 메서드, POST가 멱등이 아니라는 점, 400·401·403·409·422·500·503의 정의, §15.5.2와 §15.5.4에 따른 401(인증)과 403(인가)의 구분을 인용했습니다. 2026-08-15 확인.
- RFC 6585 — Additional HTTP Status Codes — 429의 정의("주어진 시간 동안 너무 많은 요청을 보냈다")와
Retry-After헤더가 MAY라는 점을 인용했습니다. 2026-08-15 확인. - RFC 9457 — Problem Details for HTTP APIs — RFC 7807을 대체한다는 점,
application/problem+json미디어 타입, §3.1의 다섯 멤버와detail이 문제 해결을 돕는 데 초점을 맞춰야 한다는 규정, §3.2의 확장 멤버와 클라이언트의 무시 의무, §5의 스택 덤프 노출 금지를 인용했습니다. 2026-08-15 확인. - Addressing Cascading Failures — Google SRE Book — 캐스케이딩 실패의 정의("양의 피드백의 결과로 시간이 지나면서 커지는 실패"), 서버 과부하와 자원 고갈이라는 원인, "항상 무작위화된 지수 백오프를 사용하라", 분당 60회 재시도 예산 예시, 무한 재시도 금지, 세 계층 4회 재시도가 64회가 된다는 지적, 재시도 가능·불가 오류의 구분, 일찍 거절하는 부하 차단을 인용했습니다. 2026-08-15 확인.
- Authentication Cheat Sheet — OWASP — 인증 실패 시 HTTP와 HTML 양쪽에서 일반적인 방식으로 응답해야 한다는 권고와 예시 문구, HTTP 응답 코드 차이만으로도 계정 유효 여부가 새어 나갈 수 있다는 지적을 인용했습니다. 2026-08-15 확인.
- 오류 분류의 두 축과 네 사분면, 경계별 번역 규칙 표, 재시도 가능 플래그를 도메인 오류에 싣는 방법, 타임아웃 예산 배분 방식, 오류 로그의 필수 필드, 안티패턴 목록은 위 자료에 그대로 나오는 것이 아니라 이 글에서 정리한 절차입니다.
이어서 읽기
- 이 블로그의 관련 글: CORS 에러, 서버를 고쳐야 하는 이유
- 이 블로그의 관련 글: 멱등성과 재시도: 신뢰할 수 있는 API
- 이 블로그의 관련 글: 서킷 브레이커 패턴 완벽 가이드
- 이 블로그의 관련 글: SLI/SLO/Error Budget 기반 신뢰성 엔지니어링
- 관련 도구: 재시도·누적 확률 계산기
- 관련 도구: HTTP Status Codes
완전 가이드 시리즈
The Complete Guide to Error Handling: Designing Failure as Part of the Contract
- Introduction
- 1. Two Axes for Classifying Errors
- 2. Exceptions vs Return Values — a Dispute With No Winner
- 3. Translating Errors at Boundaries
- 4. The HTTP Error Contract: Status Codes and Problem Details
- 5. Retries — No Retries Without Idempotency
- 6. Partial Failure and Timeout Budgets
- 7. Observability — Errors in Logs, Metrics, and Traces
- 8. What to Tell the User
- 9. Anti-Pattern Checklist
- Quiz: Check Your Understanding
- Conclusion
- References
- Further reading
Introduction
In most codebases the success path is designed and the failure path merely happens. The names of fields in a success response get argued over in review, while the error response gets waved through with "throw a 500 for now and clean it up later." That "later" usually arrives at an incident review.
This blog has posts that dissect a specific error, like CORS Errors, and Why You Have to Fix the Server, and posts that treat errors as a budget, like Reliability Engineering with SLIs, SLOs, and Error Budgets. But there is no post on application error design itself. This one fills that gap. It puts failure not in the category of exception syntax but in the category of interface contract, and follows what gets translated and what gets discarded each time an error crosses a layer boundary.
The sources are RFC 9110, RFC 6585, RFC 9457, the cascading failures chapter of the Google SRE book, and the OWASP Authentication Cheat Sheet.
1. Two Axes for Classifying Errors
Error design starts with classification. Without it every error funnels into a single catch (Exception e), and at that point no judgment is possible.
1-1. Axis 1 — Expected Failure or Bug
- Expected failure: an outcome the domain rules permit. Insufficient balance, a duplicate booking, an expired coupon. This is a value, and it belongs in the function signature.
- Bug: an invariant of the code is broken. Null dereference, index out of range, an unreachable branch. This is not a value but a defect, and it must not be swallowed.
Blur the distinction and two accidents happen at once: bugs get shown to users as if they were domain outcomes, and domain outcomes get reported as 500s, producing alert fatigue.
1-2. Axis 2 — Retryable or Not
- Retryable: sending the same request again shortly may succeed. Transient network errors, timeouts, 429, 503.
- Not retryable: the result is the same no matter how many times you send it. 400, 401, 403, 404, 422, and most 409s.
1-3. Four Quadrants and Their Handling
| Retryable | Not retryable | |
|---|---|---|
| Expected failure | Retry after backoff; tell the user if it still fails | Tell the user how to correct it and stop |
| Bug | Does not exist (if it does, the classification is wrong) | Log and alert; show the user a generic message |
"Expected failure that is retryable" is the quadrant that needs the most code. That is the subject of sections 5 and 6.
1-4. A Third Axis — Whose Fault Is It
The client, this server, or a downstream dependency. This axis maps straight onto the HTTP status code choice in section 4. Report a dependency failure as a 4xx and you are asking the client to fix something it cannot.
2. Exceptions vs Return Values — a Dispute With No Winner
Every language community answers this differently and none has convinced the others. Instead of a winner, here are the axes.
Exception style Return-value style
compiles even if the caller hard for the caller to forget
forgets handling
may not appear in the signature failure appears in the signature
intermediate layers write no every layer writes propagation
code code
handled far from the origin handled locally at the call site
2-1. Five Axes
- Enforcement: can the caller ignore the failure? Return values are hard to ignore; unchecked exceptions are easy to forget.
- Signature visibility: can you tell which failures are possible by reading the function alone?
- Propagation cost: how much boilerplate it takes to move an error upward. Exceptions win here by a wide margin.
- Where handling happens: do you want to deal with failure near where it occurred, or gather it far above?
- Information preservation: how much of the cause chain and context survives propagation.
2-2. What Each Approach Pays
Exception-centric languages pay near-zero propagation cost, and pay for it with not being able to tell from a signature which failures come from where. Checked exceptions lift that back into the signature, but at the cost of interfaces polluted by implementation detail and a strong tendency for developers to route around them with empty catch blocks. Return-value-centric languages express failure in the type system, and pay with propagation code at every layer plus the discipline required to attach context along the way.
There is only one practical conclusion: following the idiom of the language your team uses is the cheapest option. Fighting the idiom means fighting the library ecosystem, the static analysis tooling, and every new hire's expectations at once.
2-3. Rules That Hold Regardless of Style
- Failure must appear in either the signature or the documentation, without exception.
- Do not swallow errors. An empty catch block is code that deletes information.
- Do not express errors only as string messages. You need a type or code that can be branched on.
- Do not use exceptions for control flow. Ending a loop by exception deceives both the reader and the profiler.
3. Translating Errors at Boundaries
Most of error design is deciding what to translate and what to discard at each boundary.
external API client ─┐
├─▶ domain error ─▶ HTTP error contract ─▶ user-facing text
data store ──────────┘ │
└─▶ queue reprocessing policy
3-1. Translation Rules per Boundary
| Boundary | What comes in | What goes out | What to preserve |
|---|---|---|---|
| Store → domain | Constraint violations, connection errors | Domain error, infrastructure error | Cause chain, retryability |
| External API → domain | Status codes, timeouts | Domain error | Peer service name, correlation ID |
| Domain → HTTP | Domain error | Status code plus problem details | Error type identifier |
| Domain → queue | Domain error | Retry, delayed retry, dead letter | Attempt count, last cause |
3-2. Two Anti-Patterns
Layer leakage is the first. If a store's constraint-violation exception surfaces all the way up to the controller, the controller has to know the storage technology, and swapping the store breaks the controller.
Over-wrapping is the second. Wrap in a new exception at every layer while discarding the cause and the log ends up with five nested copies of "an error occurred during processing" and no actual cause. The rule is simple: when you wrap, always attach the cause.
3-3. Carrying the Retryable Flag Across the Boundary
There is one piece of information only the lower layer has: whether the failure was transient. If you do not carry it upward on the domain error, upper layers end up guessing from message strings. Putting retryability on the domain error type as an explicit attribute is one of the cheapest improvements this guide recommends.
4. The HTTP Error Contract: Status Codes and Problem Details
4-1. Status Codes Are Read by the Whole Stack
Here are the ones people get wrong, per RFC 9110. 400 is when the server cannot or will not process the request due to a client error; 401 is "the request lacks valid authentication credentials for the target resource"; 403 is "the server understood the request but refuses to fulfill it"; 409 is "the request conflicts with the current state of the target resource"; 422 is when the content type and syntax are understood but the instructions cannot be processed. RFC 9110 defines 401 in §15.5.2 and 403 in §15.5.4. 401 is authentication, 403 is authorization.
429 Too Many Requests lives in RFC 6585, not RFC 9110. Its text says it means "the user has sent too many requests in a given amount of time ('rate limiting')," and that the response "MAY include a Retry-After header."
4-2. Use RFC 9457 for the Body
RFC 9457 obsoletes RFC 7807 and defines the application/problem+json media type. The §3.1 members are type (a URI reference, the primary identifier of the problem type, defaulting to about:blank), status (advisory, matching the real status code), title (which, localization aside, should not change from occurrence to occurrence), detail (an explanation of this occurrence), and instance (a URI identifying this occurrence).
On detail the spec says it should "focus on helping the client correct the problem, rather than giving debugging information." That one sentence is the standard for writing error messages.
Example — an error response that also carries retryability.
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 30
{
"type": "https://api.example.com/problems/upstream-unavailable",
"title": "Upstream service unavailable",
"status": 503,
"detail": "The payment authorization service is temporarily unreachable. Send the same request again in 30 seconds.",
"instance": "/v1/payments/req_01J9XQ",
"retryable": true,
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
§3.2 permits extension members and requires clients to "ignore any such extensions that they don't recognize," so adding fields like retryable and traceId later is not a breaking change.
4-3. Make the Error Type the Only Basis for Client Branching
What clients branch on must be the type value, never the detail string. Leave that rule out of the documentation and clients start regex-matching messages, at which point fixing a phrase requires a deployment.
5. Retries — No Retries Without Idempotency
5-1. Idempotency First
RFC 9110 §9.2.2 defines idempotent as "the intended effect on the server of multiple identical requests is the same as for a single request," and includes GET, HEAD, PUT, DELETE, OPTIONS, and TRACE. POST is not idempotent. So retrying a POST requires an idempotency key, and without one that request is simply not retryable. In systems where this judgment does not exist explicitly in code, retries quietly produce duplicate payments.
5-2. Always Randomize the Backoff
The Google SRE book states flatly: "Always use randomized exponential backoff when scheduling retries." The reason is clear. Fixed intervals or pure exponential backoff bring clients that failed at the same moment back at the same moment, and that knocks over a recovering server again.
attempt 1 fails → wait = random(0, 1s)
attempt 2 fails → wait = random(0, 2s)
attempt 3 fails → wait = random(0, 4s)
cap reached → give up and propagate the error upward
5-3. Retry Budgets and Caps
The same document recommends a per-process retry budget. The value it gives as an example is "only allow 60 retries per minute in a process." It also says "don't retry a given request indefinitely." When the budget is exhausted, stop retrying and propagate the error as it is. Retries without a budget are a mechanism for doubling load at exactly the moment load is the problem.
5-4. Multi-Layer Retries Multiply
This is the most commonly missed item. The Google SRE book points out that three layers each retrying four times turns one user action into 4 × 4 × 4 = 64 attempts. It happens whenever the client SDK, the API gateway, and the service-to-service client each enable their own "reasonable" retries.
The fix is to pick one retry layer. Usually only the layer closest to the user retries, and every other layer propagates failure immediately. Then mark retries in logs and traces so the real attempt count is observable. For the relationship between retry count and success probability, the Retry and Cumulative Probability Calculator gives a feel for the numbers.
5-5. What to Retry and What Not To
- Retry: connection failures, timeouts (when idempotent), 429 (respecting
Retry-After), 503, some 500s - Do not retry: 400, 401, 403, 404, 422, most 409s
The Google SRE book says to distinguish retriable from non-retriable errors with clear error codes and never to retry permanent errors. A client that retries a 400 converts its own bug into server load.
5-6. Rejection Is Also a Strategy
The same chapter covers load shedding: reject early instead of queueing indefinitely. Reject quickly with a 503 and the client backs off while the server serves the requests that are still alive. Queue them instead and queue latency exceeds the client's timeout, burning resources producing responses nobody will receive. Deciding when to open the circuit is covered in The Complete Guide to the Circuit Breaker Pattern.
6. Partial Failure and Timeout Budgets
6-1. A Timeout Is a Budget
If a user-facing request has a three-second deadline, those three seconds are a budget shared by the downstream calls. Set each call's timeout independently and the sum exceeds the deadline, so the user has already left while the server keeps working.
user deadline 3000ms
├─ auth check 150ms
├─ order lookup 400ms
├─ payment authorize 1500ms (includes one retry → real cap 2 × 700ms)
└─ slack 950ms (serialization, GC, network variance)
Propagating the remaining time to downstream calls is what makes this budget real. A downstream service invoked after two seconds have already been spent must receive a one-second deadline. Without that propagation every service waits its own maximum.
6-2. Timeouts and Retries Multiply
A one-second timeout with three retries is three seconds in the worst case. Add an upper layer retrying again and you get the multiplication from 5-4. The timeout budget must be computed with retries included.
6-3. Designing the Partial-Failure Response
Decide the contract for "some items in a batch failed" in advance. There are three options.
- Fail the whole thing: simple, but you must be able to undo the operations that succeeded.
- Return a per-item status array: carry success and error type for each item, so the client can retry only what failed.
- Make it asynchronous behind a job resource: accept the request and let the client poll progress.
The second is the most widely used, but you must document what the overall status code will be. Leave that undecided and every client interprets it differently.
6-4. Cascading Failures
The Google SRE book defines a cascading failure as "a failure that grows over time as a result of positive feedback." The classic causes are server overload — traffic redirected from a failed cluster — and resource exhaustion across CPU, memory, threads, and file descriptors, where the exhaustions compound each other. Short on threads means higher latency, and higher latency means waiting requests hold more memory.
The lesson from an error-handling perspective is single: do not let failed requests hold resources for long. Calls without timeouts, unbounded queues, and unbounded retries all produce the same failure.
7. Observability — Errors in Logs, Metrics, and Traces
7-1. One Error, One Log Line
The most common waste is logging at every layer. Five layers logging the same error turns one error into five lines, inflating error-rate calculations and alerts alike. The rule is log once, where the error is handled. Intermediate layers attach context and propagate instead of logging.
Example — the fields an error log must carry.
{
"level": "error",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"errorType": "upstream_unavailable",
"retryable": true,
"attempt": 2,
"upstream": "payment-gateway",
"durationMs": 1043,
"message": "payment authorization failed after retry"
}
7-2. In Metrics, the Denominator Is the Point
Count errors alone and the count rises automatically with traffic. What you need is an error rate, and what the denominator is becomes a contract in itself. Whether a request that succeeded after retries counts as a success or a failure changes the number substantially, so it is better to measure both the user-perceived success rate and the per-attempt success rate. Tying error rates to an SLO is covered in Reliability Engineering with SLIs, SLOs, and Error Budgets.
7-3. Avoid Cardinality Explosions
Use error message strings as metric labels and your time series explode. Labels should carry only the finite set produced by the classification in section 1: error type, retryability, peer service name. Free-form strings belong in logs, never in metrics.
7-4. Alert on Burn Rate, Not on Errors
A system that alerts on every error is soon ignored. Alerts belong on the burn rate of the error budget, not on errors themselves. And put the correlation ID and error type in the alert body so you can jump from alert to trace in one step.
8. What to Tell the User
8-1. The Security Boundary
RFC 9457 §5 states that information in problem details "must be carefully vetted" and that you should avoid exposing "implementation details such as a stack dump." Stack traces, internal hostnames, SQL statements, and library versions are all useful to an attacker.
Authentication failures deserve particular care. The OWASP Authentication Cheat Sheet says an application "should respond (both HTTP and HTML) in a generic manner," offering wording like "Login failed; Invalid user ID or password" as the example, in order to prevent username enumeration. The same document adds that "the HTTP response code may differ which can leak information about whether the account is valid or not." Unifying the wording while letting status codes or response times diverge is no defense at all.
8-2. Three Elements of a User Message
- What happened: one sentence, no technical vocabulary
- What they can do: retry, correct a value, wait, or contact support
- An identifier for support: the correlation ID. Without it, support cannot find the incident in the logs
8-3. Tell Them How Long to Wait
For a retryable error, say how long to wait. RFC 6585 says a 429 response MAY include a Retry-After header, and the same header works for 503. The UI reads that value and renders "retrying automatically in 30 seconds." This one change sharply reduces manual refreshes, and with them the server load.
8-4. Separate Machine Values From Human Sentences
type is for machines; title and detail are for humans. That is exactly why the spec says title should not change from occurrence to occurrence. Human sentences should change often, for localization and wording improvements; machine values must never change.
9. Anti-Pattern Checklist
- Empty catch: code that deletes errors. At minimum log it, and if the ignore is deliberate, express the reason in code rather than a comment.
- Everything as a 500: mixes errors the client can fix with errors it cannot, breaking retry logic and alerting at the same time.
- Errors inside a 200: proxies, monitors, and retry middleware all read it as success.
- Returning null for an error: callers cannot distinguish "no value" from "failed," so null checks and exception handling end up interleaved.
- Branching on string matches: the error message becomes the contract, and a wording fix becomes an outage.
- Calls without timeouts: one slow dependency locks up the entire thread pool.
- Multi-layer retries: the 4 × 4 × 4 = 64 problem. Pick one retry layer.
- Logging at every layer: error rates inflate and alerts lose credibility.
- Wrapping that discards the cause: always attach the cause chain when wrapping.
- Exceptions for control flow: you lose performance and readability at once.
- Exposing stack traces to users: the pattern RFC 9457 §5 explicitly prohibits.
- No retry budget: doubles load at exactly the moment load is the problem.
Quiz: Check Your Understanding
Quiz 1: A payment request timed out. Is it safe for the client to retry automatically?
Answer: Only if the request carries an idempotency key and the server supports it. Otherwise, do not retry.
Explanation: A timeout does not even tell you whether the request reached the server. The idempotency defined in RFC 9110 §9.2.2 applies to GET, HEAD, PUT, DELETE, OPTIONS, and TRACE, not to POST, so retrying a POST payment request can produce a duplicate authorization. With an idempotency key the server answers the second request with the first one's result, which makes it safe. Carrying retryability explicitly in the error response means the client never has to guess.
Quiz 2: During an incident the error-rate dashboard spiked to five times normal, but the number of actually failed requests was nowhere near that. What do you suspect first?
Answer: Either logging at every layer, or each retry attempt being counted as its own error.
Explanation: One error logged at five layers makes the metric five times larger. Retries do the same: counted per attempt, three retries become three errors, while from the user's point of view it is one failure. The fix is to log once where the error is handled, and to measure the user-perceived success rate separately from the per-attempt success rate. You need both numbers; looking at only one misleads you in a different direction each time.
Quiz 3: The client SDK, the gateway, and the service-to-service client are each configured with four retries. What is the problem?
Answer: Retries multiply, so a single user action becomes up to 4 × 4 × 4 = 64 requests.
Explanation: This is the classic amplification path the Google SRE book names when discussing cascading failures. Apply this amplification to a downstream service already slowed by load and it knocks the recovering service over again. The fix is to pick one retry layer and have the others propagate failure immediately, plus a per-process retry budget. The same document gives 60 retries per minute per process as an example and states that a given request must not be retried indefinitely.
Quiz 4: The login screen distinguishes "no such user ID" from "wrong password." What is the risk?
Answer: Username enumeration. An attacker can confirm which accounts actually exist.
Explanation: The OWASP Authentication Cheat Sheet says an application should respond in a generic manner in both HTTP and HTML, offering "Login failed; Invalid user ID or password" as the example wording. The catch is that unified wording alone is not enough: the same document points out that a differing HTTP response code can by itself leak whether an account is valid. Differences in response time leak the same information, so those must be aligned too.
Quiz 5: A user-facing request has a three-second deadline, but its four downstream calls each have a two-second timeout. What is wrong?
Answer: The individual timeouts sum to far more than the deadline. A timeout should be a share of the deadline's budget, not an independent value.
Explanation: In the worst case it takes eight seconds, during which the user has already left while the server keeps holding resources and working. That is the resource-exhaustion path into cascading failure. The fix is to allocate each call's budget backward from the deadline and to propagate the remaining time downstream. And if retries exist, their multiplier must be included in the budget calculation.
Quiz 6: You polished the wording of an error response's detail field, and a particular screen in the mobile app stopped working. What is the root cause?
Answer: The client was branching on a human-readable sentence. Machine values and human sentences were never separated.
Explanation: RFC 9457 makes type the primary identifier of the problem type, while detail explains this occurrence and should "focus on helping the client correct the problem." In other words detail is prose for humans and may change at any time. The value clients branch on is type, and that rule has to be stated in the API documentation. Without it, wording improvements and localization all become breaking changes.
Conclusion
Error handling is not a syntax problem but a contract problem. Which failures are possible, whether each is retryable, what survives a boundary crossing, and what the user is told are all contract — which means they belong in documentation, and changing them is a breaking change.
The three cheapest improvements are these. First, put retryability on the domain error type as an explicit attribute, so upper layers stop guessing from strings. Second, pick one retry layer and have every other layer propagate failure immediately; the 4 × 4 × 4 problem disappears. Third, standardize error responses on RFC 9457 and nail down in the documentation that clients branch on type only, which buys you the freedom to fix wording.
None of the three needs a new library, and all three cost far less than a single incident.
References
- RFC 9110 — HTTP Semantics — quoted for the §9.2.2 idempotent definition and its methods, the fact that POST is not idempotent, the definitions of 400, 401, 403, 409, 422, 500, and 503, and the 401 (authentication) vs 403 (authorization) split per §15.5.2 and §15.5.4. Retrieved 2026-08-15.
- RFC 6585 — Additional HTTP Status Codes — quoted for the definition of 429 ("the user has sent too many requests in a given amount of time") and the fact that
Retry-Afteris a MAY. Retrieved 2026-08-15. - RFC 9457 — Problem Details for HTTP APIs — quoted for obsoleting RFC 7807, the
application/problem+jsonmedia type, the five §3.1 members and the rule thatdetailshould focus on helping the client correct the problem, §3.2 extension members and the client's obligation to ignore unrecognized ones, and the §5 prohibition on exposing stack dumps. Retrieved 2026-08-15. - Addressing Cascading Failures — Google SRE Book — quoted for the definition of cascading failure ("a failure that grows over time as a result of positive feedback"), server overload and resource exhaustion as causes, "always use randomized exponential backoff when scheduling retries," the 60-retries-per-minute budget example, the prohibition on retrying indefinitely, the observation that three layers retrying four times each becomes 64 attempts, the distinction between retriable and non-retriable errors, and load shedding by rejecting early. Retrieved 2026-08-15.
- Authentication Cheat Sheet — OWASP — quoted for the recommendation that authentication failures respond generically in both HTTP and HTML with the example wording, and the observation that a differing HTTP response code can leak whether an account is valid. Retrieved 2026-08-15.
- The two classification axes and their four quadrants, the per-boundary translation table, carrying a retryable flag on the domain error, the timeout budget allocation, the required fields of an error log, and the anti-pattern checklist are not taken verbatim from the sources above; they are the procedure organized in this post.
Further reading
- Related post on this blog: CORS Errors, and Why You Have to Fix the Server
- Related post on this blog: Idempotency and Retries: APIs You Can Trust
- Related post on this blog: The Complete Guide to the Circuit Breaker Pattern
- Related post on this blog: Reliability Engineering with SLIs, SLOs, and Error Budgets
- Related tool: Retry and Cumulative Probability Calculator
- Related tool: HTTP Status Codes
Complete Guide Series