오류 처리
상태 코드와 오류 본문의 형태, 재시도 규칙.
오류 본문의 모양
게이트웨이가 상위 모델로 보내기 전에 직접 막은 요청은 429 와 함께 아래 본문으로 돌아옵니다. 코드 하나만 오는 게 아니라 사람이 읽을 수 있는 한 문장이 같이 오기 때문에, 최종 사용자에게 그대로 보여 주면 됩니다.
type GateReason = "insufficient_balance" | "budget_exceeded" | "rate_limited";
interface GateBody {
error: GateReason; // 코드 문자열 — === 로 비교하면 된다
message: string; // 사람이 읽는 평문 한 문장 (HTML 아님)
topup_url?: string; // 충전으로 풀리는 경우에만 — insufficient_balance 뿐이다
}error는 코드 문자열입니다 —=== "insufficient_balance"처럼 비교하세요. 문구(message)로 분기하면 안내 문장을 다듬는 순간 깨집니다.message는 평문입니다. HTML 이 아니라서 터미널·로그에 찍어도 태그가 섞이지 않고, 화면에 넣을 때는textContent를 쓰면 됩니다.topup_url은 URL 문자열입니다. 버튼으로 만들지 링크로 둘지는 앱이 정합니다.
message 를 innerHTML 로 꽂지 마세요
응답이 어디로 갈지는 게이트웨이가 모릅니다. 평문으로 주는 이유가 그것이고, innerHTML 로 넣으면 게이트웨이 응답이 그대로 DOM 이 되는 통로가 열립니다.
// 게이트웨이 응답을 화면에 넣을 때
el.textContent = e.message; // ✅ 평문 그대로 — 태그가 섞일 일이 없다
el.innerHTML = e.message; // ❌ 쓰지 말 것 — 응답을 DOM 으로 만드는 XSS 통로가 된다429 세 가지를 구분하기
같은 429 라도 사용자가 할 수 있는 일이 전혀 다릅니다. 셋을 뭉뚱그리면 충전하면 풀리는 상황에도 "잠시 후 다시 시도"만 띄우게 됩니다.
insufficient_balance— 잔액이 0원입니다. 충전하면 즉시 재개되고, 이 코드에만topup_url이 붙습니다. 키를 다시 발급할 필요는 없습니다. 여기까지 오기 전에 미리 알고 싶다면 GET /v1/balance 로 잔액을 확인할 수 있습니다.budget_exceeded— 키에 걸린 월 예산을 다 썼습니다. 충전으로는 풀리지 않아topup_url이 없습니다. 관리자에게 문의해야 합니다.rate_limited— 분당 요청 한도입니다. 잠시 기다렸다 다시 보내면 됩니다.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"error": "insufficient_balance",
"message": "잔액이 모두 소진되었습니다. 사용량 패키지를 구매하면 즉시 다시 이용할 수 있습니다.",
"topup_url": "https://ai.cubion.kr/app/billing"
}HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"error": "budget_exceeded",
"message": "이 키에 설정된 월 예산을 모두 사용했습니다. 관리자에게 문의해 주세요."
}
// topup_url 이 없다 — 충전해도 풀리지 않는 상황이라 일부러 안 보낸다.충전 버튼은 topup_url 이 있을 때만
topup_url 유무로 분기하면 세 가지를 따로 외울 필요가 없습니다. 값이 있으면 충전 버튼을 띄우고, 없으면 message 만 보여 주세요. 잔액이 어떻게 빠지는지는 요금·잔액 에 있습니다.
재시도 규칙
429 응답에는 기다릴 시간(초)을 알려 주는 Retry-After 헤더가 붙습니다. 상위 모델이 내린 429 를 전달할 때도 상위가 준 값을 그대로 넘깁니다. 드물게 상위가 값을 주지 않는 경우가 있으니 Number(res.headers.get("Retry-After") ?? 5) 처럼 기본값을 두고 읽으세요.
import os, time, requests
URL = "https://ai.cubion.kr/v1/chat/completions"
HEAD = {
"Authorization": f"Bearer {os.environ['GATEWAY_KEY']}",
"Content-Type": "application/json",
}
def call_gateway(body, retried=False):
res = requests.post(URL, headers=HEAD, json=body)
if res.ok:
return res.json()
try:
e = res.json()
except ValueError:
e = {}
if e.get("error") == "insufficient_balance":
show_notice(e.get("message", "")) # 평문이라 그대로 띄워도 안전하다
if e.get("topup_url"): # 이 코드에만 붙는다
show_topup_button(e["topup_url"])
return None
if e.get("error") == "budget_exceeded":
show_notice(e.get("message", "")) # 충전으로는 안 풀린다 — 충전 버튼을 띄우지 말 것
return None
if e.get("error") == "rate_limited" and not retried:
# Retry-After 가 없는 경로도 있으므로 기본값을 둔다. 재시도는 한 번만.
wait = int(res.headers.get("Retry-After") or 5)
time.sleep(wait)
return call_gateway(body, retried=True)
raise RuntimeError(e.get("message") or e.get("error") or f"HTTP {res.status_code}")const KEY = process.env.GATEWAY_KEY;
// 429 를 한곳에서 처리해 두면, 사용자는 "왜 안 되지"가 아니라 "충전하면 되는구나"를 보게 된다.
async function callGateway(body, retried = false) {
const res = await fetch("https://ai.cubion.kr/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
const e = await res.json().catch(() => ({}));
if (e.error === "insufficient_balance") {
showNotice(e.message); // 평문 — textContent 로 넣는다
if (e.topup_url) showTopupButton(e.topup_url); // 이 코드에만 붙는다
return null;
}
if (e.error === "budget_exceeded") {
showNotice(e.message); // 충전으로는 안 풀린다 — 충전 버튼을 띄우지 말 것
return null;
}
if (e.error === "rate_limited" && !retried) {
// Retry-After 가 없는 경로도 있으므로 기본값을 둔다. 재시도는 한 번만 —
// 막힌 채로 계속 두드리면 한도가 풀리지 않는다.
const wait = Number(res.headers.get("Retry-After") ?? 5);
await new Promise((r) => setTimeout(r, wait * 1000));
return callGateway(body, true);
}
throw new Error(e.message ?? e.error ?? `HTTP ${res.status}`);
}- 재시도는 한 번만. 막힌 채로 계속 두드리면 한도가 풀리지 않고, 그대로 재시도 폭주가 됩니다.
insufficient_balance·budget_exceeded는 재시도 대상이 아닙니다. 기다린다고 풀리지 않으니 곧바로 사용자에게 알리세요.401·403·400도 재시도하지 마세요. 요청이나 키를 고쳐야 하는 오류입니다.
요청 추적 헤더
모든 /v1 응답(오류 포함)에 아래 헤더가 붙습니다. 문의할 때 X-Request-Id 하나만 알려 주시면 해당 요청의 기록을 바로 찾을 수 있습니다.
| 헤더 | 뜻 |
|---|---|
X-Request-Id | 게이트웨이가 요청마다 만든 ID(UUID). 우리 쪽 요청 기록과 같은 값입니다. |
X-Client-Request-Id | 요청에 이 헤더를 보내면 기록에 함께 남고 응답에도 그대로 돌아옵니다. 문서 ID·작업 ID 처럼 여러분 쪽 식별자를 넣으세요(영문·숫자·기호 128자까지). |
X-Gateway-Attempts | 상위 호출 횟수. 1 보다 크면 다른 키·모델로 폴백했거나 한도 때문에 재시도했다는 뜻입니다. |
X-Gateway-Model | 실제로 처리한 모델. feature: 이름으로 불렀거나 폴백 모델로 넘어갔을 때 확인하세요. |
curl -i https://ai.cubion.kr/v1/chat/completions \
-H "Authorization: Bearer $GATEWAY_KEY" \
-H "X-Client-Request-Id: wi-2026-00412" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4.1","messages":[{"role":"user","content":"안녕"}]}'
HTTP/1.1 200 OK
x-request-id: 3f0c2a4e-8a51-4a6f-9b3e-0d2f7c1e9a10
x-client-request-id: wi-2026-00412
x-gateway-attempts: 1
x-gateway-model: gpt-4.1상태 코드
게이트웨이가 내보내는 오류는 아래가 전부입니다.
| 상태 | error | 언제 / 어떻게 |
|---|---|---|
| 401 | unauthorized | 키가 틀렸거나 폐기·비활성 상태입니다. API 키 에서 다시 발급하세요. |
| 403 | scope_forbidden | 키 권한 밖의 엔드포인트입니다({"error":"scope_forbidden","need":"ai"}). 포털에서 발급한 키는 /v1/* AI 호출만 쓸 수 있습니다. |
| 403 | model_not_allowed | 이 키로 쓸 수 없는 모델입니다. 응답에 model 이 함께 옵니다 — 모델 목록 이 내려주는 범위에서 고르세요. |
| 429 | insufficient_balance | 잔액이 0원입니다. topup_url 로 보내 충전하면 즉시 재개됩니다. |
| 429 | budget_exceeded | 키에 걸린 월 예산을 다 썼습니다. 충전으로는 풀리지 않아 topup_url 이 없습니다 — 관리자에게 문의해 주세요. |
| 429 | rate_limited | 분당 요청 한도를 넘었습니다. Retry-After 초만큼(없으면 몇 초) 기다린 뒤 한 번만 재시도하세요. |
| 400 | invalid_json · missing model | 본문이 JSON 이 아니거나 model 이 비었습니다. |
| 400 | input_too_long · unsupported_format | 음성 합성 에서 입력 길이나 response_format 이 모델이 받는 범위를 넘었습니다. 응답에 허용치(max · supported)가 함께 옵니다. |
| 502 | upstream_unreachable · no_upstream_for_model | 상위 모델 쪽 장애이거나 그 모델을 받아 줄 곳이 없습니다. |
| 502 | upstream_auth_failed | 게이트웨이 뒤의 제공사 키가 거절됐습니다(운영 쪽 문제). 다른 키가 있으면 자동으로 넘어가고, 모두 거절되면 이 코드가 옵니다. 운영자에게 자동으로 알림이 갑니다 — 몇 분 뒤 다시 시도하세요. |
| 503 | upstream_quota_exhausted | 게이트웨이 뒤의 AI 제공사 쪽 이용 한도가 소진됐습니다. 여러분의 잔액과는 무관하고, 운영자에게 자동으로 알림이 갔습니다. 운영자가 조치하기 전에는 다시 불러도 풀리지 않습니다. |
400 은 요청을 고쳐야 풀립니다. 502 는 잠시 후 다시 부르면 대개 풀리지만, 계속 같은 모델에서만 난다면 다른 모델로 바꿔 보세요. 503 upstream_quota_exhausted 는 짧은 간격으로 재시도하지 마세요 — 몇 분 단위로 다시 확인하거나, 다른 제공사의 모델로 바꾸면 됩니다.