---
title: "에러 코드"
description: "Apinoa API에서 반환하는 에러 코드 전체 레퍼런스입니다."
---

## 에러 응답 형식

모든 에러 응답은 일관된 엔벨로프 형식을 따릅니다:

```json
{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "사람이 읽을 수 있는 에러 설명"
  }
}
```

게이트웨이 엔드포인트는 추적용 `requestId`도 포함합니다:

```json
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Your balance does not cover this call. Top up at https://apinoa.com/dashboard/balance."
  },
  "requestId": "550e8400e29b41d4a716446655440000"
}
```

## 마켓플레이스 API 에러

`gateway.apinoa.com`에서 반환되는 에러입니다. 모든 에러에 `requestId`가 포함되니, 지원팀에 문의할 때 함께 알려주세요.

### 계정

| 코드 | 상태 | 의미 |
|------|------|------|
| `UNAUTHORIZED` | 401 | `x-api-key` 헤더가 없거나, 키가 폐기·만료되었거나 알 수 없는 키 |
| `INSUFFICIENT_BALANCE` | 402 | 호출 비용이 남은 잔액보다 큼. **대시보드 > 잔액**에서 충전하세요 |
| `FORBIDDEN` | 403 | 키는 유효하지만 이 호출은 허용되지 않음 |
| `ACCOUNT_SUSPENDED` | 403 | 계정이 정지됨: 정지가 해제될 때까지 이 계정의 모든 키가 거부됩니다. 고객 지원팀에 문의하세요 |

반드시 처리해야 할 것은 `402`입니다. 사용량 기반 과금에서 이는 오류가 아니라 잔액이 소진된 정상적인 상태입니다 —
재시도로는 해결되지 않으며, 충전하기 전까지 모든 호출이 같은 응답을 반환합니다.

### 백프레셔

| 코드 | 상태 | 의미 |
|------|------|------|
| `CAPACITY_SATURATED` | 429 | 이 호출을 처리할 여유 용량이 없었음. 마켓플레이스 쪽 문제는 아닙니다 |
| `MARKETPLACE_SATURATED` | 429 | 위와 같으나 특정 마켓플레이스에 한정됨 |

두 에러 모두 초 단위의 `Retry-After`를 포함합니다. 고정값이 아니라 실제 추정치이니 그만큼 기다린 뒤 재시도하세요.
둘 다 과금되지 않습니다.

### 요청

| 코드 | 상태 | 의미 |
|------|------|------|
| `UNKNOWN_MARKETPLACE` | 404 | 존재하지 않는 마켓플레이스. `GET /v1/marketplaces`로 목록을 확인하세요 |
| `UNKNOWN_OPERATION` | 404 | 해당 마켓플레이스가 이 오퍼레이션을 지원하지 않음 |
| `INVALID_PARAMS` | 400 | 파라미터가 없거나, 알 수 없거나, 범위를 벗어남. 메시지에 해당 파라미터 이름이 나옵니다 |
| `QUERY_REQUIRED` `IMAGE_REQUIRED` `CATEGORY_REQUIRED` | 400 | 오퍼레이션의 필수 입력이 빠짐 |
| `CATEGORY_NOT_BROWSABLE` | 400 | 상품이 없는 탐색용 상위 카테고리. 같은 id로 `categories`를 호출해 하위 카테고리를 탐색하세요 |
| `BROWSABLE_ONLY_UNAVAILABLE` | 400 | 측정 데이터가 없는 마켓플레이스에 `browsableOnly=true`를 사용함. 파라미터를 빼면 전체 분류를 받습니다 |
| `INVALID_CURSOR` | 400 | 이 오퍼레이션이 발급한 `cursor`가 아님. 커서 없이 다시 시작하세요 |
| `PAGE_NOT_SUPPORTED` | 400 | 이 오퍼레이션은 `page`가 아니라 `cursor`로 페이지를 넘깁니다 |
| `CURRENCY_NOT_SUPPORTED` | 400 | 마켓플레이스가 요청한 통화로 가격을 제공하지 않음 |
| `INVALID_PRODUCT_ID` | 400 | 이 마켓플레이스가 쓰는 형식의 id가 아님 |
| `PRODUCT_NOT_FOUND` | 404 | 마켓플레이스에 해당 상품이 없음 |
| `REVIEWS_NOT_FOUND` | 404 | 상품은 존재하며 실제로 리뷰가 하나도 없음 |

`400`은 과금되지 않습니다.

### 지원 범위

| 코드 | 상태 | 의미 |
|------|------|------|
| `OPERATION_UNSUPPORTED` | 501 | 오퍼레이션은 있지만 이 입력에는 지원되지 않음 |
| `REVIEW_APP_UNSUPPORTED` | 501 | 판매자가 아직 지원하지 않는 앱으로 리뷰를 게시함 |
| `REVIEWS_UNREACHABLE` | 501 | 리뷰 개수나 평점으로 보아 리뷰가 존재하지만, 하나도 가져오지 못함 |

`501`은 의도적으로 `404`나 빈 목록과 구분됩니다. 데이터는 존재하지만 읽어오지 못했다는 뜻입니다.

### 업스트림

| 코드 | 상태 | 의미 |
|------|------|------|
| `UPSTREAM_ERROR` | 502 | 마켓플레이스가 사용할 수 없는 응답을 반환함 |
| `UPSTREAM_UNAVAILABLE` | 502 | 마켓플레이스가 응답하지 않음 |
| `UPSTREAM_READ_FAILED` | 502 | 응답이 중간에 끊김 |
| `UPSTREAM_TIMEOUT` | 502 | 마켓플레이스가 제시간에 응답하지 않음 |
| `SERVICE_UNAVAILABLE` | 503 | 저희 쪽의 일시적 오류. 재시도하세요 |

**아무것도 전달하지 못한 호출은 상태 코드와 관계없이 과금되지 않습니다.** 지수 백오프로 재시도하세요.
이 문서에 적힌 코드가 전부입니다. 마켓플레이스 오류는 언제나 이 중 하나로 전달되며, 더 세부적인 코드가
그대로 나가지는 않습니다. `requestId`를 알려주시면 지원팀이 세부 내용을 확인할 수 있습니다.

## 플랫폼 에러

특정 마켓플레이스와 무관하게, 어떤 경로에서든 어느 마켓플레이스에든 돌아올 수 있는 에러입니다.

| 코드 | 상태 | 의미 |
|------|------|------|
| `BAD_REQUEST` | 400 | 요청 자체를 읽을 수 없음: 형식이 잘못된 본문, 해석할 수 없는 쿼리 문자열 |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | JSON 엔드포인트에 `application/json`이 아닌 본문을 보냄 |
| `PAYLOAD_TOO_LARGE` | 413 | 요청 본문이 이 엔드포인트가 받는 크기를 넘음 |
| `NOT_FOUND` | 404 | 해당 경로에 라우트가 없음 |
| `NOT_IN_V1` | 404 | `/v1` 이전에 있던 경로로, v1에는 포함되지 않음. 본문의 `successor` 필드와 `Link: …; rel="successor-version"` 헤더가 대체 경로를 알려줍니다 |
| `RATE_LIMITED` | 429 | API 키의 초당 요청 수가 허용치를 넘음. `Retry-After` 초만큼 기다린 뒤 다시 시도하세요. 거절된 호출은 과금되지 않습니다 |
| `QUOTA_EXCEEDED` | 429 | 이 API를 사용할 수 있는 과금 설정이 계정에 없음 |
| `INTERNAL_ERROR` | 500 | 저희 쪽 오류. 재시도하고, 계속되면 `requestId`를 알려주세요 |

## 이미지 번역 에러

| 코드 | 상태 | 의미 |
|------|------|------|
| `INVALID_IMAGE` | 422 | 이미지로 읽을 수 없는 바이트 |
| `UNSUPPORTED_IMAGE_FORMAT` | 422 | 읽을 수는 있으나 번역을 지원하지 않는 형식 |
| `IMAGE_TOO_LARGE` | 413 | 이미지의 픽셀 크기가 이 엔드포인트의 허용 범위를 넘음. 메시지에 실제 크기와 상한이 함께 표시됩니다 |
| `NETWORK_ERROR` | 504 | 이미지 URL이 제시간에 응답하지 않음 |
| `TRANSLATION_FAILED` | 502 | 텍스트는 읽었으나 번역하지 못함 |
| `RENDER_FAILED` | 502 | 번역문을 이미지에 다시 그리지 못함 |
| `ROUTING_INCIDENT` | 502 | 요청을 처리하지 못함. 재시도하세요 |
| `WORKER_USER_FAULT` | 4xx | 요청 자체가 거부됨 — 상태 코드와 메시지가 이유를 설명합니다 |

`FONT_*` 코드는 맞춤 폰트에 속하며
[맞춤 폰트](/docs/api-reference/image-translate/custom-fonts) 문서에 정리되어 있습니다. 번역 호출에서
직접 반환될 수 있는 것이 둘 더 있습니다. 비공개 폰트를 소유하지 않은 키로 지정했을 때의
`FONT_AUTH_REQUIRED`(401), 폰트를 불러오지 못했을 때의 `FONT_RESOLVE_ERROR`(500)입니다.

## AliExpress 에러

| 코드 | 상태 | 의미 |
|------|------|------|
| `ITEM_NOT_FOUND` | 404 | 마켓플레이스에 해당 상품이 존재하지 않음 |
| `PROHIBITED_COUNTRY` | 403 | 요청한 국가로의 배송 불가 |
| `TOKEN_EXPIRED` | 503 | AliExpress를 일시적으로 사용할 수 없음. 나중에 재시도하세요 |

AliExpress 자체 에러 번호가 있는 경우, 응답 본문의 `upstreamCode` 필드와 `X-Apinoa-Upstream-Code` 응답 헤더에
담겨 있습니다.

## 에러 처리

### 재시도 전략

일시적 에러(`UPSTREAM_*` 계열, `SERVICE_UNAVAILABLE`, `TOKEN_EXPIRED`)는 지수 백오프를 구현하세요.
`CAPACITY_SATURATED`와 `MARKETPLACE_SATURATED`는 추측하지 말고 응답에 담긴 `Retry-After`만큼 기다리세요:

```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.ok) {
      return response.json();
    }

    const error = await response.json();
    const code = error.error?.code;

    // 클라이언트 에러는 재시도하지 않음
    // INSUFFICIENT_BALANCE는 저절로 해결되지 않음 — 재시도해도 재시도 횟수만 소모됨
    if (["INSUFFICIENT_BALANCE", "UNAUTHORIZED", "FORBIDDEN", "ACCOUNT_SUSPENDED", "INVALID_PARAMS",
         "ITEM_NOT_FOUND", "PRODUCT_NOT_FOUND"].includes(code)) {
      throw new Error(`${code}: ${error.error.message}`);
    }

    // 일시적 에러는 지수 백오프로 재시도
    if (attempt < maxRetries - 1) {
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }

  throw new Error("Max retries exceeded");
}
```

```bash
# curl을 사용한 간단한 재시도
for i in 1 2 3; do
  response=$(curl -s -w "\n%{http_code}" -X POST \
    https://gateway.apinoa.com/v1/aliexpress/search \
    -H "Content-Type: application/json" \
    -H "x-api-key: YOUR_API_KEY" \
    -d '{"query": "phone case"}')

  http_code=$(echo "$response" | tail -1)

  if [ "$http_code" -eq 200 ]; then
    echo "$response" | head -1
    break
  fi

  sleep $((2 ** i))
done
```
