오류
모든 비즈니스 코드와 HTTP 상태, 대응 방법과 재시도가 유효한 경우를 안내합니다.
호출에 문제가 있으면 응답은 종류를 나타내는 숫자 code와 사람이 읽는 문장 msg를 제공합니다. 모든 코드의 의미와 재시도 효과를 설명합니다. 증상별 빠른 해결은 문제 해결부터 보세요.
응답 형식
네이티브 작업 API JSON은 항상 같은 네 필드를 사용합니다. /v1 호환 응답과 SSE는 각 프로토콜에 맞춰 파싱하세요.
{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }code는 HTTP 상태가 아닌 비즈니스 코드입니다.200은 성공입니다.- 실패 시
data는null이 아니라 없으며,code,msg,request_id를 받습니다. 단, 일부 에셋 라이브러리 실패에는 예외적으로data.reason이 붙습니다. msg는 사람이 읽는 설명으로 문구·언어가 바뀔 수 있습니다. 메시지로 분기하지 말고code를 사용하세요.request_id는X-Request-Id헤더에도 있으며 신고 시 포함하세요.
HTTP 상태도 정확히 설정됩니다. 둘 중 하나만 사용하세요. 세부 코드에서 다릅니다(잔액 부족은 code: 40201, HTTP 402). 섞으면 놓치는 경우가 생깁니다.
code 사용을 권장합니다. 404는 없다는 뜻만 표현하지만 40201은 사용 가능 잔액 부족을 정확히 나타냅니다.
모든 응답이 JSON은 아닙니다
엣지의 일반 텍스트·HTML 등 JSON으로 파싱되지 않는 응답에는 code가 없습니다. HTTP 상태로 처리하고 본문 앞부분을 기록하세요. 403을 무작정 반복하지 마세요. 재시도로 해결되지 않는 지역 제한일 수 있습니다.
오류 메시지 언어
사람이 읽는 오류는 선택한 언어로 받을 수 있으며 기본은 영어입니다. 다음 세 부분만 따릅니다.
- 응답 바깥쪽
msg - 실패 작업의
recordInfo와 webhookerrorMessage(아래 예외 있음) - OpenAI, Anthropic, Google Gemini(
/v1beta) 호환 오류의error.message
언어 코드: en, ja, ko, de, fr, es, pt-BR, ru, it, pl.
예외: 모델이 참조 이미지 읽기 실패·생성 오디오 저작권 제한 등 구체적 이유를 제공하면 errorMessage는 이유를 영어로 담습니다. 서비스명·호스트·URL·요청 ID 등 내부 정보를 제거하여 실제 원인을 알려 줍니다. 계정·과금 상태를 언급한 이유는 전달하지 않고 플랫폼 메시지를 사용합니다. Content-Language는 여전히 msg의 언어이며 분기 기준은 항상 errorCode입니다.
언어 선택
다음 우선순위를 따릅니다.
- 요청
Accept-Language헤더: 해당 요청에만 적용. 표준 HTTP 가중치에 따라 인식한 언어 중 최고q를 선택합니다. 지역 변형은 언어로 합칩니다(de-DE→de,pt-PT→pt-BR). 중국어는 제공하지 않아 다음 후보를 시도하며*는 선호 없음입니다. - 콘솔 설정 → API 오류 언어의 계정 설정: 인식 가능한
Accept-Language없는 계정 키 요청에 적용됩니다. 대부분 서버 SDK·curl은 기본 헤더를 보내지 않아 이 설정을 사용합니다. 콜백도 요청 헤더가 없어 항상 계정 설정을 따릅니다. - 나머지는 영어입니다.
오류 응답 Content-Language에 실제 작성 언어를 표시합니다.
사람이 읽는 문구만 바뀝니다
code, errorCode, OpenAI 호환 type·code, Gemini 호환 code·status, 모든 필드명은 언어마다 같습니다. 문구를 파싱하지 말고 코드로 분기하며 로그·알림에 code, request_id를 기록하세요.
예시
같은 잔액 부족 40201은 기본 영어입니다.
HTTP/1.1 402 Payment Required
Content-Language: en
{ "code": 40201, "msg": "Insufficient balance", "request_id": "req_…" }빠른 시작과 같은 생성 호출에 Accept-Language: ja만 추가합니다.
curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $SPICY_IDEMPOTENCY_KEY" \
-H "Accept-Language: ja" \
--data "$TASK_PAYLOAD"msg는 일본어이고 code는 여전히 40201입니다.
HTTP/1.1 402 Payment Required
Content-Language: ja
{ "code": 40201, "msg": "残高が不足しています", "request_id": "req_…" }일반 코드
code | HTTP | 의미 | 재시도 |
|---|---|---|---|
200 | 200 | 성공 | — |
400 | 400 | 잘못된 요청·매개변수 | 불가. 수정 후 제출 |
40003 | 400 | 업로드 크기·유형·내용이 URL 요청 선언과 불일치 | 불가. 정확한 contentType·크기의 새 URL로 업로드·확정 |
40004 | 400 | 유효 요청이나 현재 설정 조합 실행 불가 | 불가. msg의 해상도·비율 등을 변경하거나 모델 변경. 아래 참조 |
401 | 401 | 인증 실패. 세 경우는 msg로 설명 | 불가. 헤더·키 수정 |
403 | 403 | 허용되지 않음. 유효 키면 계정 제한. 이메일 미인증은 40310 | 불가 |
404 | 404 | 없거나 자신의 것이 아님 | 불가 |
409 | 409 | 현재 상태와 충돌 | 경우에 따라 가능. 아래 참조 |
40901 | 409 | 견적 만료·요청 변경·가격 변경 | 다시 견적·확인. 승인 요청 복구는 원래 멱등성 키 |
413 | 413 | 요청 본문 상한 초과 | 불가. 본문 축소·미디어 업로드 |
429 | 429 | 요청 과다 | 보통 가능. Retry-After 대기. 속도와 무관한 429는 미사용 업로드 참조 |
500 | 500 | 플랫폼 장애 | 가능. 간격을 늘려 재시도 |
503 | 503 | 견적·인라인 이미지 저장·미디어 업로드 지원 기능 일시 장애 | 가능. 있으면 Retry-After를 따르고 재시도. createTask는 같은 Idempotency-Key. Retry-After 없이 지속되면 msg에 따라 인라인 대신 공개 HTTPS 등 사용 |
503과 50301은 HTTP는 같지만 별도 비즈니스 코드입니다. 503은 지원 기능, 50301은 모델 문제입니다.
세 가지 401 구분
서로 다른 세 인증 문제가 401이며 대응도 다릅니다. code는 항상 401이고 msg가 구분합니다.
영어 msg | 실제 상황 | 수정 사항 |
|---|---|---|
Missing credentials: send your API key in the Authorization header as "Bearer sk-spicy-..."(호환 /v1·/v1beta: …as "Authorization: Bearer sk-spicy-...", x-api-key, or x-goog-api-key) | 인증 정보 없이 도착 | 클라이언트 헤더 누락. 인터셉터·프록시와 실행 프로세스의 환경 변수 로드 확인 |
API key format is not recognized: keys begin with sk-spicy- | 다른 종류 인증 정보 | 콘솔 세션이나 타 서비스 키 복사 여부 확인. 접두사는 sk-spicy-이며 echo "${SPICY_API_KEY:0:9}"로 확인 |
Credentials are invalid or expired | 형식은 맞지만 없음·해지·만료 | API 키 상태 확인 후 새 키. 반복해도 200이 되지 않음 |
두 번째는 DB 조회 없이 판단하여 문자열 자체가 작동할 수 없음을 알려 줍니다. 세 번째는 키 존재 여부 탐색을 막도록 없음·해지·만료의 문구를 같게 합니다.
여기서는 영어를 표시하지만 msg는 위 언어 설정을 따르며 문구는 계약이 아닙니다. code로 분기하고 메시지는 읽기·표시·기록용으로만 사용하세요.
계정이 API를 호출할 수 없는 경우
두 경우 모두 키는 유효하지만 계정이 아직 API를 사용할 수 없습니다.
**40310**은 이메일 미인증이며 Verify your account email to run models and call the API…를 안내합니다.
- 콘솔의 상단 미인증 배너에서 인증 메일을 다시 보내세요.
- 인증 링크를 여세요. 스팸함도 확인하세요.
- 같은 호출을 반복하세요. 새 키는 필요 없습니다.
API가 제한되어도 콘솔은 작동하여 내역을 볼 수 있습니다.
일반 **403**과 This account cannot call the API right now는 계정 제한입니다. request_id를 포함하여 지원에 문의하세요.
잔액과 한도
code | HTTP | 의미 | 대응 |
|---|---|---|---|
40201 | 402 | 예상 비용을 잔액으로 충당 못함 | 콘솔 과금에서 충전. 계정 설정, GET /api/v1/chat/credit 확인 |
40202 | 402 | 지출 한도 도달 | msg 확인. 키(This key has reached its …)는 키 페이지에서 상향·초기화 대기. 팀(Workspace … budget has been reached)은 소유자·관리자·예산 관리자에게 요청. 플랫폼(Platform quota for today is exhausted…)은 변경 불가, UTC 00:00 후 재시도. 일 한도는 UTC 00:00, 월 한도는 매월 1일 UTC 00:00, 누적 한도는 초기화 없음 |
반복 요청으로 잔액·한도가 바뀌지 않아 둘 다 재시도 이점이 없습니다. 지출 한도 참조.
권한
code | HTTP | 의미 | 대응 |
|---|---|---|---|
40301 | 403 | 키에 모델 권한 없음 | 콘솔 모델 허용 목록 확대 |
40302 | 403 | 출발지 주소가 IP 목록에 없음 | 추가하거나 키 IP 제한 해제 |
40303 | 403 | 지역 서비스 미제공 | 우회 불가. 제공 지역 참조 |
40310 | 403 | 이메일 미인증으로 실행 불가 | 가입 인증 링크 또는 콘솔 재발송. 인증 즉시 키가 작동하며 별도 변경 없음 |
모두 재시도로 해결되지 않습니다.
에셋 라이브러리 원인 코드
에셋 라이브러리 엔드포인트(/api/v1/assets…)의 실패에는 기본 응답 외피에 기계가 읽을 수 있는 필드 data.reason이 하나 더 붙을 수 있습니다.
{ "code": 400, "msg": "…", "data": { "reason": "asset_library_locked" }, "request_id": "req_…" }code, HTTP 상태, msg, 작업의 errorCode는 원인 코드가 있든 없든 같으므로 기존 처리 로직은 그대로 동작합니다. 원인 코드는 에셋 라이브러리 엔드포인트에서만 나타나고 다른 네이티브 엔드포인트에는 나타나지 않으며, OpenAI 호환 등 호환 계층의 오류 형식도 바뀌지 않습니다. 목록은 늘어날 수 있으니 모르는 값은 원인 코드가 없는 것과 같게 처리하세요.
data.reason | HTTP | 의미 | 대응 |
|---|---|---|---|
asset_library_locked | 400 | 에셋 라이브러리는 첫 충전 후에 열립니다 | 충전하세요. 이미 저장한 에셋은 계속 사용할 수 있습니다 |
asset_library_full | 400 | 저장 한도(개수 또는 용량)가 가득 찼습니다 | 에셋을 삭제하거나 상위 플랜으로 올리세요. 다음 플랜은 GET /api/v1/assets/usage에서 확인할 수 있습니다 |
asset_file_too_large | 400 | 파일이 플랜의 파일당 상한을 넘습니다 | 더 작은 파일을 쓰거나 상위 플랜으로 올리세요 |
asset_daily_limit | 429 | 하루 신규 생성 한도에 도달했습니다 | UTC 00:00까지 기다리고 Retry-After를 따르세요 |
asset_storage_full | 503 | 플랫폼 전체 저장소가 가득 찼습니다 | 잠시 후 다시 시도하세요 |
asset_permission_denied | 403 | 팀 내 역할로는 이 변경이 허용되지 않습니다 | 팀 소유자나 관리자에게 요청하거나, 허용된 역할의 멤버가 만든 키를 사용하세요 |
asset_workspace_forbidden | 403 | 이 키는 해당 라이브러리에 닿지 않습니다(예: 팀 키를 만든 멤버가 팀을 떠남) | 필요한 워크스페이스에서 새 키를 만드세요 |
asset_entity_name_taken | 409 | 라이브러리에 같은 이름의 캐릭터가 이미 있습니다(대소문자 무시). 캐릭터 이름은 웹 앱의 Assets 페이지에서 정하며, 읽기 전용 API는 현재 이 값을 돌려주지 않습니다 | 다른 이름을 고르세요 |
모델 가용성
code | HTTP | 의미 | 재시도 |
|---|---|---|---|
50301 | 503 | 현재 모델 사용 불가 | 가능. 나중에 재시도·다른 모델 |
50302 | 503 | 스트리밍 여부와 무관하게 답 전달 전 텍스트 모델 실패, 선점 환불 | 가능. 새 Idempotency-Key |
40004 | 400 | 모델 실행 가능하지만 이 설정은 불가 | 불가. msg의 설정 변경 |
50301은 제출 시점의 승인 불가입니다. 미공개·활성 가격 없음·현재 생성 용량 없음 등의 이유입니다. 일시 용량 문제는 횟수 제한 재시도로 해결될 수 있지만 모델 미제공·가격 없음은 반복으로 해결되지 않습니다. 카탈로그를 갱신하고 모델 변경 전에 이용자 확인을 받으세요.
40004와 50301은 의도적으로 다릅니다. 50301은 어떤 작업도 못 받아 대기가 도움 될 수 있습니다. 40004는 모델은 작동하나 이 설정 조합을 못 받고 msg에 괄호로 설정을 표시합니다(예: This model cannot serve this parameter combination right now (…)). 기다리지 말고 값을 바꾸세요. 둘 다 작업 생성·금액 선점은 없습니다.
승인 후 생성 실패는 state: failed입니다
createTask가 ID를 반환한 후 실패는 recordInfo의 state: "failed", errorCode / errorMessage로 나타납니다. 일반적으로 전액 해제하나 모델 페이지에 명시한 일부 과금 거절은 예외입니다. 40004와 같은 문제가 승인 후 발견되면 errorCode: "unsupported_combination"입니다. 이 페이지에 없는 동기 비즈니스 코드 분기를 추가하지 마세요.
작업 실패 코드
승인 후 실패는 recordInfo의 errorCode입니다. 집합은 고정이며 내부 진단은 공개하지 않습니다. 목록 밖 값은 upstream_failed로 처리하세요.
errorCode | 의미 | 재시도 |
|---|---|---|
invalid_request | 매개변수 미승인 | 불가. 수정 후 제출 |
unsupported_combination | 현재 미지원 조합 | 불가. 설정 변경 |
content_rejected | 선택 모델의 콘텐츠 거절. SpicyAPI는 요청을 검토하지 않음. 책임 참조 | 불가. 프롬프트·모델 변경 |
rate_limited | 생성 서비스 사용 중 | 가능. 간격을 늘려 재시도 |
upstream_unavailable | 생성 일시 불가 | 가능. 잠시 후 재시도 |
invalid_asset | 참조 사용 불가 | retry로는 불가. retry는 원입력을 사용하므로 다시 업로드하고 새 URI로 새 작업 생성 |
generation_failed | 시작했지만 결과 없음 | 경우에 따라 가능. 같은 입력은 보통 같은 실패 |
timeout | 기한까지 미완료 | 경우에 따라 가능. 길이·해상도 축소 |
upstream_failed | 기타 실패 | 가능. 간격을 늘려 재시도 |
실패·만료는 보통 전액 해제합니다. 명시한 일부 콘텐츠 거절만 승인 견적 과금입니다. settled, cost로 확인하세요. errorMessage는 사람이 읽는 이유이고 모델 영어 이유를 포함할 수 있으며(위) 문구가 바뀝니다. errorCode만으로 분기하세요. 여기의 재시도는 성공하면 다시 과금되는 새 작업입니다.
비용이 발생하는 오류
작업 상태와 연결 상태는 서로 다릅니다
명시적으로 거절된 생성, 만료 작업과 과거 취소는 생성 비용이 없습니다. 실패는 보통 무료이며 모델 페이지에 명시한 과금 콘텐츠 거절만 예외입니다. 클라이언트 시간 초과·연결 해제는 작업 실패가 아닙니다. 승인 작업은 취소 불가하므로 원래 작업의 최종 상태·정산을 복구하세요. 유효 사용량이 기록된 텍스트 스트림은 연결 해제해도 과금될 수 있습니다.
| 상황 | 과금 |
|---|---|
작업 생성 없이 createTask 명시 거절(예: 40201, 40202, 40004, 50301) | 없음. 생성·선점은 한 트랜잭션. 게이트웨이 오류·응답 누락만으로 거절 확정 불가 |
| 제출 시간 초과·탭 닫기·연결 해제 | 아직 불명. 원래 멱등성 키로 승인을 복구. 무료 재시도로 판단하지 않기 |
createTask의 202 후 failed / expired | 보통 없음. 모델 페이지에 명시한 일부 failed / content_rejected는 승인 견적 과금. settled, cost 확인 |
과거 canceled | 없음. 현재 동작 아닌 호환 상태 |
createTask의 202 후 succeeded | 있음. 실사용량 정산, 승인 선점액 상한. 미사용액 해제, 이후 초과 징수 없음 |
recordInfo, chat/credit, common/* 오류 | 없음. 이 엔드포인트는 과금하지 않음 |
호출 제한 429 또는 멱등 중복 제거 | 없음. 전자는 미승인, 후자는 한 번 과금되는 원래 작업 반환 |
프로세스 중단 뒤에도 정산·해제는 자동 완료합니다. 일반 실패는 전액 해제, 명시된 일부 거절은 승인 견적 과금입니다. 작업 정산·잔액으로 확인하세요. 과금 참조.
재시도가 유효한 409
| 상황 | 재시도 |
|---|---|
같은 키와 변경 없는 본문의 드문 동시 createTask 충돌 | 가능. 수백 밀리초 후 같은 키. 멱등성 참조 |
| 다른 본문·API 키가 이미 같은 키 사용 | 불가. 소유 관계 수정 |
인라인 이미지·업로드 확정 처리 중(Retry-After 가능) | 가능. Retry-After 또는 수초 대기 후 같은 Idempotency-Key |
| 요청 중 계정 가격·설정 변경 | 가능. 견적을 사용하면 재견적 후 전송 |
| 결과물 복사 중 | 가능. 수초 후 링크 재요청 |
미종료·미정산 작업의 jobs/retry | 나중에 가능. 종료 및 settled가 true 후 |
성공 또는 보관 만료 입력 제거 작업의 jobs/retry | 불가. createTask 새 생성 |
| 업로드 URL·파일 만료 | 불가. 다시 업로드 |
재시도 코드
무작정 반복하지 마세요
400, 401, 40004, 40201 등 입력·권한 오류는 반복해도 같습니다. 위의 ‘불가’ 시도는 낭비이며 자체 호출 허용량을 사용합니다.
지수 백오프와 무작위 지연으로 다수 실패가 같은 순간 재시도하지 않도록 합니다.
// 409 is operation-specific and must be handled at the call site.
const RETRYABLE = new Set([429, 500, 503, 50301]);
async function callWithRetry(path, init, { attempts = 4 } = {}) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(`https://api.spicyapi.ai/api/v1${path}`, init);
// A page from the edge network is not our JSON envelope
if (!res.headers.get('content-type')?.includes('json')) {
const text = await res.text();
throw new Error(`HTTP ${res.status}, not JSON: ${text.slice(0, 200)}`);
}
const body = await res.json();
if (body.code === 200) return body.data;
if (!RETRYABLE.has(body.code)) {
throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
}
// When the server says how long to wait, trust it
const retryAfter = Number(res.headers.get('retry-after'));
const backoff = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 2 ** i * 1000 + Math.random() * 500;
await new Promise((r) => setTimeout(r, backoff));
}
throw new Error('out of retries');
}import random, time, requests
# 409 is operation-specific and must be handled at the call site.
RETRYABLE = {429, 500, 503, 50301}
BASE = "https://api.spicyapi.ai/api/v1"
def call_with_retry(method: str, path: str, attempts: int = 4, **kwargs) -> dict:
for i in range(attempts):
res = requests.request(method, f"{BASE}{path}", **kwargs)
# A page from the edge network is not our JSON envelope
try:
body = res.json()
except ValueError:
raise RuntimeError(f"HTTP {res.status_code}, not JSON: {res.text[:200]}")
if body["code"] == 200:
return body["data"]
if body["code"] not in RETRYABLE:
raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
# When the server says how long to wait, trust it
retry_after = res.headers.get("retry-after")
backoff = float(retry_after) if retry_after else 2**i + random.random() * 0.5
time.sleep(backoff)
raise RuntimeError("out of retries")package main
import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"strconv"
"time"
)
// retryable lists the codes worth another attempt. Anything else returns
// the same answer however many times you ask — and each ask eats your own
// rate limit.
// Whether the generation itself failed is read from recordInfo's state.
var retryable = map[int]bool{429: true, 500: true, 503: true, 50301: true}
type envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
RequestID string `json:"request_id"`
}
func callWithRetry(newReq func() (*http.Request, error), attempts int, out any) error {
for i := 0; i < attempts; i++ {
req, err := newReq()
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
// Transport-level failure: the request may well have arrived.
// On createTask, send the same Idempotency-Key or this retry
// turns into a second generation and a second charge.
time.Sleep(backoff(i, ""))
continue
}
var env envelope
decErr := json.NewDecoder(res.Body).Decode(&env)
io.Copy(io.Discard, res.Body)
res.Body.Close()
if decErr != nil {
// Not our JSON envelope, for example a page from the edge network.
return fmt.Errorf("HTTP %d, response is not JSON: %w", res.StatusCode, decErr)
}
if env.Code == 200 {
return json.Unmarshal(env.Data, out)
}
if !retryable[env.Code] {
return fmt.Errorf("%d %s (request_id=%s)", env.Code, env.Msg, env.RequestID)
}
// When the server says how long to wait, trust it
time.Sleep(backoff(i, res.Header.Get("Retry-After")))
}
return fmt.Errorf("out of retries")
}
// backoff trusts Retry-After when present, otherwise backs off
// exponentially with jitter so a batch of failures does not come back
// as one thundering herd.
func backoff(attempt int, retryAfter string) time.Duration {
if s, err := strconv.Atoi(retryAfter); err == nil && s > 0 {
return time.Duration(s) * time.Second
}
base := time.Duration(1<<attempt) * time.Second
return base + time.Duration(rand.Intn(500))*time.Millisecond
}createTask 재시도는 **항상 같은 Idempotency-Key**를 보내세요. 그렇지 않으면 실제 성공했지만 응답만 유실된 경우 중복 생성·과금됩니다.
디버깅
이해하기 어려운 오류는 다음 순서로 확인하세요.
request_id— 정확한 요청을 찾는 데 필요합니다.code— 위 표를 참고하세요.- 콘솔 로그 — 로그에 ID·
request_id를 검색하여 상태, 비용, 지연, 호출 키, 오류 코드·이유를 확인하세요. 입력·결과는 보관 중에만 표시하며 팀에서는 콘텐츠 읽기 권한이 필요합니다.
500의 msg는 항상 일반적인 서비스 일시 사용 불가 메시지입니다. 내부 구조·구성 요소명·스택 추적은 응답에 나오지 않으며 request_id로만 추적합니다.
콘솔 지원이나 support@spicyapi.ai로 문의하세요. request_id 또는 작업 ID, UTC 시각과 code를 포함하며 API 키는 보내지 마세요.

