ZUKU API — 오류 · 응답 봉투
> 브랜드: ZUKU API
> 구현 기준: backend/rs/src/router.rs (envelope_ok / envelope_err / format_response), upload.rs
> 클라이언트의 ApiFailure(code, message, status) 패턴과 맞춘다.
응답 봉투 (Envelope)
모든 JSON API 응답은 공통 봉투를 사용한다 (models.rs · ApiMeta / ApiError).
성공
{
"success": true,
"data": { },
"meta": {
"request_id": "…",
"timestamp": "2026-08-22T00:00:00Z",
"version": "v1"
}
}
data: 엔드포인트별 페이로드meta.version: 현재 고정"v1"(URL/api/v1· 헤더X-API-Version과 동일 세대)
실패
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "입력값을 확인해 주세요",
"details": [
{ "field": "title", "message": "제목은 1~100자여야 합니다" }
]
},
"meta": {
"request_id": "…",
"timestamp": "…",
"version": "v1"
}
}
details는 필드 단위 검증이 있을 때만 포함 (envelope_err_details)- 단순 실패는
code+message만 (envelope_err)
예외
| 상황 | 본문 |
|---|---|
204 No Content |
빈 본문 (댓글 삭제·일부 폐기 API 등) |
Jump stream / swf 성공 |
JSON이 아닌 바이너리 가능 |
| CORS preflight | 204 |
HTTP 상태 코드 (구현에서 쓰는 범위)
| HTTP | 대표 상황 |
|---|---|
200 OK |
조회·갱신·토글 성공 |
201 Created |
생성(콘텐츠·업로드·가입·글 등) |
204 No Content |
본문 없는 성공 / OPTIONS |
308 Permanent Redirect |
레거시 Jump 경로 → 정규 API 안내 |
400 Bad Request |
JSON/multipart 형식 오류 (BAD_REQUEST) |
401 Unauthorized |
세션/토큰 없음·무효 (UNAUTHORIZED) |
403 Forbidden |
권한 부족 (FORBIDDEN) |
404 Not Found |
리소스/경로 없음 (코드는 리소스별) |
409 Conflict |
중복·상태 충돌 (EMAIL_EXISTS, HANDLE_EXISTS, ALREADY_AUTHENTICATED, LEGACY_ACCOUNT_EXISTS, SELF_ACTION_FORBIDDEN 등) |
413 Payload Too Large |
업로드 한도 (PAYLOAD_TOO_LARGE) |
415 Unsupported Media Type |
매직바이트 미지원 (UNSUPPORTED_MEDIA_TYPE) |
422 Unprocessable Entity |
필드 검증 (VALIDATION_ERROR, Jump ID 검증 코드 등) |
500 Internal Server Error |
서버/DB (INTERNAL_ERROR, DB_UNAVAILABLE) |
501 Not Implemented |
스코프 아웃 (NOT_IMPLEMENTED, 예: OAuth) |
503 Service Unavailable |
캡차 미설정 등 (CAPTCHA_NOT_CONFIGURED) |
에러 코드 목록
router.rs / upload.rs에서 envelope_err · envelope_err_details · json_not_found로 실제로 나가는 코드다. (모듈 위임 moderation/analytics의 동일 코드 포함 가능)
인증 · 권한
| code | 전형적 HTTP | 메시지 요지 |
|---|---|---|
UNAUTHORIZED |
401 | Bearer 세션/Authorization 필요, 유효한 access_token 필요 |
FORBIDDEN |
403 | 권한 없음 · 관리자 전용 |
ALREADY_AUTHENTICATED |
409 | 이미 로그인된 상태로 회원가입 시도 |
SELF_ACTION_FORBIDDEN |
409 | 자기 자신에 대한 금지된 관리 조작 |
검증 · 요청 형식
| code | 전형적 HTTP | 메시지 요지 |
|---|---|---|
BAD_REQUEST |
400 | 요청 본문/ multipart 형식 오류 |
VALIDATION_ERROR |
422 | 필드 검증 실패 (details[] 동반) |
INVALID_CONTENT_ID |
422 | Jump play ID 문자/길이 규칙 위반 |
CONTENT_ID_MISMATCH |
422 | Jump 경로 ID ≠ 본문 content_id |
회원가입 · 계정
| code | 전형적 HTTP | 메시지 요지 |
|---|---|---|
EMAIL_EXISTS |
409 | 이메일 중복 |
HANDLE_EXISTS |
409 | handle 중복 |
LEGACY_ACCOUNT_EXISTS |
409 | 주전자닷컴 레거시 계정과 충돌 — 기존 로그인 유도 |
캡차
| code | 전형적 HTTP | 메시지 요지 |
|---|---|---|
CAPTCHA_FAILED |
(검증 실패) | PoW/캡차 실패 |
CAPTCHA_NOT_CONFIGURED |
503 | CAPTCHA_HMAC_SECRET 미설정 — fail-closed |
리소스 Not Found (세분화)
| code | 전형적 HTTP | 대상 |
|---|---|---|
NOT_FOUND |
404 | 일반(세션·캡차 경로·업로드 파일 등) |
CONTENT_NOT_FOUND |
404 | 콘텐츠 |
COMMENT_NOT_FOUND |
404 | 댓글 |
POST_NOT_FOUND |
404 | 커뮤니티 글 |
CONVERSATION_NOT_FOUND |
404 | DM 대화 |
USER_NOT_FOUND |
404 | 회원 |
NOTIFICATION_NOT_FOUND |
404 | 알림 |
API_KEY_NOT_FOUND |
404 | 개발자 API 키 |
ROUTE_NOT_FOUND |
404 | 매칭되는 API 경로 없음 |
미디어 · Jump
| code | 전형적 HTTP | 메시지 요지 |
|---|---|---|
UNSUPPORTED_MEDIA_TYPE |
415 | 지원하지 않는 업로드 형식 |
PAYLOAD_TOO_LARGE |
413 | 업로드 크기 한도 초과 |
SOURCE_UNAVAILABLE |
(재생 실패) | 재생 가능한 원본 없음 |
SWF_PARSE_FAILED |
(재생 실패) | SWF 해석/IR 인코딩/비지원 형식 |
인프라 · 기타
| code | 전형적 HTTP | 메시지 요지 |
|---|---|---|
DB_UNAVAILABLE |
500 | DB 풀/접속 불가 |
INTERNAL_ERROR |
500 | 내부 처리 실패 |
NOT_IMPLEMENTED |
501 | 미구현(예: OAuth provider) |
> 프론트 ApiFailure는 봉투 파싱 실패 시 INVALID_RESPONSE, 빈 본문 오류 시 HTTP_ERROR를 클라이언트 측에서 만들 수 있다. 이는 서버 envelope_err 코드가 아니다.
details[] 필드 오류
VALIDATION_ERROR 등에서:
"details": [
{ "field": "title", "message": "제목은 1~100자여야 합니다" },
{ "field": "tags", "message": "태그는 최대 10개입니다" }
]
콘텐츠 생성 시 흔한 field 값: title, description, tags, age_rating, type.
UI는 details를 필드별 인라인 에러로 매핑하고, 없으면 error.message를 토스트/배너로 쓴다.
응답 헤더
format_response가 모든 응답에 붙인다.
| 헤더 | 현재 동작 |
|---|---|
X-API-Version |
고정 v1 |
X-RateLimit-Limit |
더미 정적값 1000 |
X-RateLimit-Remaining |
더미 정적값 999 |
X-RateLimit-Reset |
대략 now + 60초(Unix) — 실제 쿼터 차감 없음 |
> 현재 백엔드는 Rate Limit을 강제하지 않는다. 헤더는 계약 자리표시자(dummy)다. 클라이언트가 Remaining으로 UX를 잠그면 안 된다.
그 외: Access-Control-Allow-Origin: *, 허용 메서드/헤더(Authorization, Content-Type) 등.
처리 팁 (JS / ApiFailure 스타일)
class ApiFailure extends Error {
constructor(
public code: string,
message: string,
public status: number,
public details?: { field: string; message: string }[],
) {
super(message);
this.name = 'ApiFailure';
}
}
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api/v1${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
});
if (res.status === 204) return undefined as T;
const envelope = await res.json();
if (!envelope.success) {
throw new ApiFailure(
envelope.error.code,
envelope.error.message,
res.status,
envelope.error.details,
);
}
return envelope.data as T;
}
// 사용 예
try {
await apiFetch('/contents', { method: 'POST', body: JSON.stringify(payload), headers: {
Authorization: `Bearer ${token}`,
}});
} catch (e) {
if (e instanceof ApiFailure) {
switch (e.code) {
case 'UNAUTHORIZED':
// 로그인 유도
break;
case 'VALIDATION_ERROR':
// e.details 로 폼 하이라이트
break;
case 'EMAIL_EXISTS':
case 'HANDLE_EXISTS':
// 가입 폼 전용 메시지
break;
case 'CAPTCHA_FAILED':
// 캡차 재시도
break;
case 'DB_UNAVAILABLE':
case 'INTERNAL_ERROR':
// 잠시 후 재시도
break;
default:
console.error(e.code, e.message, e.status);
}
}
}
관련 페이지
- contents.md
- media.md
- changelog.md —
meta.version·X-API-Version