---
title: "에러"
description: "API가 돌려주는 모든 에러 코드, HTTP 상태, 그리고 대처 방법."
---

> Every apinoa docs page as markdown: https://apinoa.com/llms.txt (all in one file: https://apinoa.com/llms-full.txt)

## 에러 형식

모든 에러는 같은 봉투를 씁니다. `success: false`, 바뀌지 않는 `code`와 사람이 읽는 `message`를 담은 `error`,
그리고 고객 지원에 문의할 때 알려 줄 `requestId`(`x-request-id` 헤더로도 보냅니다)입니다.

```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"
}
```

분기는 `message`가 아니라 `code`로 하세요. 메시지는 문구가 바뀔 수 있지만, 코드는 `v1` 안에서 바뀌지 않습니다. **실패한
호출은 과금되지 않습니다.**

## Marketplace API와 SNS API

### 계정

| 상태 | 코드 | 의미 |
|---|---|---|
| 401 | `UNAUTHORIZED` | `x-api-key` 헤더가 없거나, 폐기·만료되었거나 알 수 없는 키입니다. |
| 402 | `INSUFFICIENT_BALANCE` | 호출 비용이 남은 크레딧보다 큽니다. **대시보드 > 잔액**에서 충전하세요. |
| 403 | `ACCOUNT_SUSPENDED` | 계정이 정지되어 그 계정의 모든 키가 거부됩니다. 고객 지원에 문의하세요. |
| 403 | `FORBIDDEN` | 키는 유효하지만 이 키로는 이 호출을 할 수 없습니다. |

처리해야 할 것은 `402`입니다. 선불 잔액에서는 결함이 아니라 크레딧이 정상적으로 소진된 것입니다. 다시 시도해도
해결되지 않습니다.

### 요청

| 상태 | 코드 | 의미 |
|---|---|---|
| 400 | `BROWSABLE_ONLY_UNAVAILABLE` | 해당 데이터가 없는 마켓플레이스에 `browsableOnly=true`를 보냈습니다. 파라미터를 빼세요. |
| 400 | `CATEGORY_NOT_BROWSABLE` | 상품이 없는 탐색용 단계의 카테고리입니다. `categories`에서 하위 카테고리를 골라 탐색하세요. |
| 400 | `CATEGORY_REQUIRED` | 오퍼레이션에 필요한 카테고리가 없습니다. |
| 400 | `CURRENCY_NOT_SUPPORTED` | 마켓플레이스가 요청한 통화로 가격을 매기지 않습니다. |
| 400 | `IMAGE_REQUIRED` | 오퍼레이션에 필요한 이미지가 없습니다. |
| 400 | `INVALID_CURSOR` | 이 오퍼레이션이 돌려준 `cursor`가 아닙니다. 커서 없이 처음부터 다시 호출하세요. |
| 400 | `INVALID_PARAMS` | 파라미터가 없거나, 알 수 없거나, 범위를 벗어났습니다. 메시지에 이름이 나옵니다. |
| 400 | `INVALID_PRODUCT_ID` | 이 마켓플레이스가 쓰는 형식의 id가 아닙니다. |
| 400 | `PAGE_NOT_SUPPORTED` | 이 오퍼레이션은 `page`가 아니라 `cursor`로 페이지를 넘깁니다. |
| 400 | `QUERY_REQUIRED` | 오퍼레이션에 필요한 키워드가 없습니다. |
| 400 | `VIDEO_TOO_LARGE` | 영상 파일이 이 API가 돌려주는 크기(64 MB)보다 큽니다. |
| 404 | `NOT_FOUND` | 계정·게시물·그룹·컬렉션·장소·앱이 없거나, 비공개·삭제·연령 제한 상태입니다. |
| 404 | `NOT_SHOPIFY_STORE` | `shopify/catalog`에 Shopify 스토어가 아닌 사이트를 넘겼습니다. |
| 404 | `PRODUCT_NOT_FOUND` | 마켓플레이스에 해당 상품이 없습니다. |
| 404 | `REVIEWS_NOT_FOUND` | 상품은 있지만 실제로 리뷰가 없습니다. Google Shopping에서는 한 상점의 상품이라 Google이 리뷰를 보관하지 않는 경우입니다. |
| 404 | `STORE_CLOSED` | Shopify 스토어가 비밀번호로 보호되거나 닫혀 있어 카탈로그가 공개되지 않습니다. |
| 404 | `UNKNOWN_MARKETPLACE` | 해당 플랫폼이 없습니다. `GET /v1/marketplaces`가 목록을 보여 줍니다. |
| 404 | `UNKNOWN_OPERATION` | 해당 플랫폼에 이 오퍼레이션이 없습니다. |

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

### 백프레셔

| 상태 | 코드 | 의미 |
|---|---|---|
| 429 | `CAPACITY_SATURATED` | 지금은 이 호출을 처리할 여유가 없습니다. 플랫폼에는 문제가 없습니다. |
| 429 | `MARKETPLACE_SATURATED` | 위와 같으며, 한 플랫폼에 해당합니다. |

둘 다 초 단위의 `Retry-After` 헤더를 담습니다. 그만큼 기다린 뒤 다시 시도하세요. 고정값이 아니라 실제
추정치입니다. 둘 다 과금되지 않습니다.

### 지원 범위

| 상태 | 코드 | 의미 |
|---|---|---|
| 501 | `OPERATION_UNSUPPORTED` | 오퍼레이션은 있지만 이 입력에는 지원하지 않습니다. |
| 501 | `REVIEW_APP_UNSUPPORTED` | 스토어가 이 API가 아직 읽지 못하는 앱으로 리뷰를 게시합니다. |
| 501 | `REVIEWS_UNREACHABLE` | 리뷰는 있지만(개수나 평균이 그렇게 말합니다) 하나도 읽을 수 없었습니다. |

`501`은 일부러 `404`나 빈 목록으로 응답하지 않는 것입니다. 데이터는 있지만 읽을 수 없었다는 뜻입니다.

### 업스트림

| 상태 | 코드 | 의미 |
|---|---|---|
| 502 | `UPSTREAM_ERROR` | 플랫폼이 쓸 수 없는 응답을 보냈습니다. |
| 502 | `UPSTREAM_READ_FAILED` | 플랫폼의 응답이 중간에 끊겼습니다. |
| 502 | `UPSTREAM_TIMEOUT` | 플랫폼이 제때 응답하지 않았습니다. |
| 502 | `UPSTREAM_TOO_LARGE` | 플랫폼의 응답이 너무 커서 돌려줄 수 없습니다. |
| 502 | `UPSTREAM_UNAVAILABLE` | 플랫폼이 응답하지 않았습니다. |
| 503 | `SERVICE_UNAVAILABLE` | 우리 쪽의 일시적인 장애입니다. 다시 시도하세요. |

이 코드들은 지수 백오프로 다시 시도하세요. 이것이 전부입니다. 플랫폼 장애는 항상 이 중 하나로
보고됩니다.

## 플랫폼 공통 에러

어느 경로에서든 돌아올 수 있습니다.

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

## Image Translate

| 상태 | 코드 | 의미 |
|---|---|---|
| 4xx | `WORKER_USER_FAULT` | 요청 자체가 거부되었습니다. 상태와 메시지에 이유가 나옵니다. |
| 413 | `IMAGE_TOO_LARGE` | 이미지의 픽셀 크기가 한도를 넘습니다. 메시지에 둘 다 나옵니다. |
| 422 | `INVALID_IMAGE` | 읽을 수 있는 이미지 데이터가 아닙니다. |
| 422 | `UNSUPPORTED_IMAGE_FORMAT` | 읽을 수는 있지만 번역하지 않는 형식의 이미지입니다. |
| 502 | `RENDER_FAILED` | 번역문을 이미지에 다시 그릴 수 없었습니다. |
| 502 | `ROUTING_INCIDENT` | 요청을 처리할 수 없었습니다. 다시 시도하세요. |
| 502 | `TRANSLATION_FAILED` | 텍스트는 읽었지만 번역할 수 없었습니다. |
| 504 | `NETWORK_ERROR` | 이미지 URL이 제때 응답하지 않았습니다. |

`FONT_*` 코드는 맞춤 폰트에 속하며
[Custom Fonts](/docs/api-reference/image-translate/custom-fonts)에 정리되어 있습니다. 그중 두 개는 번역 호출
자체에서도 돌아올 수 있습니다. 비공개 폰트를 그 폰트를 소유한 키 없이 지정하면 `FONT_AUTH_REQUIRED`(401),
폰트를 불러올 수 없으면 `FONT_RESOLVE_ERROR`(500)입니다.

## AliExpress

| 상태 | 코드 | 의미 |
|---|---|---|
| 403 | `PROHIBITED_COUNTRY` | 요청한 국가로는 배송할 수 없습니다. |
| 404 | `ITEM_NOT_FOUND` | AliExpress에 해당 상품이 없습니다. |
| 503 | `TOKEN_EXPIRED` | AliExpress를 일시적으로 사용할 수 없습니다. 나중에 다시 시도하세요. |

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

## 재시도

다시 시도해서 성공할 수 있는 것만 재시도하세요. `UPSTREAM_*` 계열, `SERVICE_UNAVAILABLE`,
`INTERNAL_ERROR`, `TOKEN_EXPIRED`는 지수 백오프로, `429` 코드는 `Retry-After`만큼 기다린 뒤
재시도합니다. `429` 이외의 `4xx`는 재시도하지 마세요. 똑같이 실패합니다.

```js tab="JavaScript"
const RETRYABLE = new Set(["CAPACITY_SATURATED", "MARKETPLACE_SATURATED", "RATE_LIMITED", "SERVICE_UNAVAILABLE",
  "INTERNAL_ERROR", "TOKEN_EXPIRED", "UPSTREAM_ERROR", "UPSTREAM_READ_FAILED", "UPSTREAM_TIMEOUT", "UPSTREAM_UNAVAILABLE"]);

async function call(url, init = {}, attempts = 4) {
  for (let i = 0; ; i++) {
    const res = await fetch(url, { ...init, headers: { ...init.headers, "x-api-key": process.env.APINOA_API_KEY } });
    const body = await res.json();
    if (body.success) return body.data;
    if (!RETRYABLE.has(body.error.code) || i === attempts - 1) throw new Error(`${body.error.code}: ${body.error.message}`);
    const wait = Number(res.headers.get("retry-after")) || 2 ** i;
    await new Promise((r) => setTimeout(r, wait * 1000));
  }
}
```

```python tab="Python"
import os
import time

import requests

RETRYABLE = {"CAPACITY_SATURATED", "MARKETPLACE_SATURATED", "RATE_LIMITED", "SERVICE_UNAVAILABLE",
             "INTERNAL_ERROR", "TOKEN_EXPIRED", "UPSTREAM_ERROR", "UPSTREAM_READ_FAILED", "UPSTREAM_TIMEOUT",
             "UPSTREAM_UNAVAILABLE"}

def call(url, params=None, attempts=4):
    for i in range(attempts):
        res = requests.get(url, params=params, headers={"x-api-key": os.environ["APINOA_API_KEY"]})
        body = res.json()
        if body["success"]:
            return body["data"]
        if body["error"]["code"] not in RETRYABLE or i == attempts - 1:
            raise RuntimeError(f'{body["error"]["code"]}: {body["error"]["message"]}')
        time.sleep(float(res.headers.get("retry-after") or 2 ** i))
```

```bash tab="cURL"
for i in 1 2 3 4; do
  status=$(curl -s -o response.json -w "%{http_code}" \
    "https://gateway.apinoa.com/v1/walmart/search?query=wool+socks" \
    -H "x-api-key: $APINOA_API_KEY")
  case "$status" in
    200) cat response.json; break ;;
    429|5??) sleep $((2 ** i)) ;;
    *) cat response.json; break ;;
  esac
done
```
