에러
API가 돌려주는 모든 에러 코드, HTTP 상태, 그리고 대처 방법.
에러 형식
모든 에러는 같은 봉투를 씁니다. success: false, 바뀌지 않는 code와 사람이 읽는 message를 담은 error,
그리고 고객 지원에 문의할 때 알려 줄 requestId(x-request-id 헤더로도 보냅니다)입니다.
{
"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에 정리되어 있습니다. 그중 두 개는 번역 호출
자체에서도 돌아올 수 있습니다. 비공개 폰트를 그 폰트를 소유한 키 없이 지정하면 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는 재시도하지 마세요. 똑같이 실패합니다.
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));
}
}