# 코드에서 오류, 요청 한도, 타임아웃은 어떻게 처리합니까?

오류, 한도, 신뢰 · error.code로 분기하고, Retry-After에 따라 물러나고, timeout과 busy는 한 번 재시도하며, 400은 절대 재시도하지 않기 — 이를 수행하는 클라이언트를 네 가지 언어로

*API 가이드 · 10분 · 중급 · 개발자 · 400 · 429 · 503 · Retry-After*

모든 거부 응답은 `{"error": {"code", "message", "request_id"}}`입니다. `code`로 분기하십시오. `400`은 메시지에 해결 방법이 있으며 재시도할 가치가 전혀 없습니다. `429 rate_limited`는 단 하나의 토큰 버킷(분당 요청 1,200건, 초당 20건, 버스트 40건)이 비었다는 뜻이므로 `Retry-After`만큼 기다리십시오. `503`은 네 가지 코드 중 하나입니다. `timeout`(`Retry-After: 5`만큼 기다린 뒤 한 번 재시도), `busy`(무거운 주소 조회들이 정해진 수의 슬롯을 공유함, 대응은 같음), `snapshot_pending`(배치가 아직 만들지 않은 보기, `Retry-After: 60`), `not_ready`(`/ready`에서만)입니다. 오류 형식이 없는 `5xx`는 스캐너가 아니라 엣지에서 온 것입니다. 물러났다가 재시도하고, 절대 빈 응답으로 읽지 마십시오. 아래에 이 모든 것을 수행하는 클라이언트가 있습니다.

## 오류 형식과 각 상태가 요구하는 것

| 상태 | `code` | 해야 할 일 |
|---|---|---|
| `400` | `invalid_chain`, `invalid_address`, `invalid_limit`, `unknown_param`, `invalid_cursor`, `invalid_tx`, `invalid_window`, `bad_request`, … | `message`를 읽고 요청을 고친 뒤 다시 보내십시오. 바꾸지 않은 채 재시도하지 마십시오. |
| `404` | `not_found` | 그런 라우트가 없거나(메시지가 매뉴얼을 알려 줌) 그런 주체가 없음. 주소에 대한 판정이 아님 |
| `405` | `method_not_allowed` | `Allow` 헤더에 있는 메서드를 쓰십시오 |
| `429` | `rate_limited` | `Retry-After`만큼 기다린 뒤 다시 보내십시오. 쉼 없이 반복하지 마십시오 |
| `503` | `timeout` | 조회가 기한을 넘겼습니다(*the read behind GET … did not finish within the 5s request deadline; retrying may succeed*). `Retry-After`(5)만큼 기다린 뒤 한 번 재시도하십시오 |
| `503` | `busy` | 무거운 주소 조회를 위한 빈 슬롯이 없음. `Retry-After`(5)만큼 기다린 뒤 백오프를 적용해 재시도하십시오 |
| `503` | `snapshot_pending` | 아직 만들어지지 않은 보기에 대한 `/v1/stats/home`. `Retry-After`(60)만큼 기다리십시오 |
| `503` | `not_ready` | `/ready`: 데이터베이스가 응답하지 않음 |
| `500` | `internal` | 스캐너 자체의 오류. `request_id`를 함께 알려 주십시오 |
| JSON 오류 형식이 없는 `5xx` | — | 스캐너가 아니라 Cloudflare(`Retry-After: 120`, `request_id` 없음). 물러났다가 재시도하십시오. 대상에 대해서는 아무것도 말하지 않습니다 |

오류는 아니지만 여전히 조치를 요구하는 응답이 세 가지 있습니다. `/graph`의 **`202`**(`status: "building"`, `retry_after_seconds` 후에 반복), **`304`**(가지고 있는 본문을 유지), 그리고 **`no-store`로 제공된 `200`**(일부가 유실됨, 다시 요청)입니다. 모든 접점의 모든 상태와 정확한 메시지는 [에이전트용 코드 페이지](https://achivx.kr/academy/for-agents/every-answer-code-on-every-door/)에 있습니다.

## 한도

클라이언트마다 토큰 버킷이 하나 있습니다. 분당 요청 1,200건이며, 에이전트 카드의 `rateLimits`에 공개되어 있고 스킬에서는 초당 20건, 버스트 40건으로 명시합니다. 이것은 작업량이 아니라 요청을 제한합니다. 한도 안의 무거운 조회도 타임아웃될 수 있습니다. [MCP](https://stablescan.achivx.com/mcp)는 같은 속도로 별도의 예산을 가집니다. 대량 작업을 초당 몇 건의 속도로 조절하면 `429`를 보지 않고, 폴러를 각 라우트의 `max-age`([표](https://achivx.kr/academy/api/paging-and-caching-in-code/))에 맞춰 조절하면 같은 바이트를 두 번 요청하지 않습니다.

## 이를 수행하는 클라이언트

아래의 각 클라이언트는 [첫 요청](https://achivx.kr/academy/api/first-request/)의 헬퍼를 감쌉니다. 응답을 분류하고, `429`, `timeout`, `busy`에서는 `Retry-After`만큼 기다리며, 이들을 약간의 지터와 함께 최대 `retries`번 재시도하고, `400`에서는 즉시 예외를 일으키며, 일으키는 모든 오류에 `request_id`를 드러냅니다. 대기는 Python과 Go 버전에서는 블로킹이고 TypeScript에서는 await됩니다. 여러분의 스케줄러로 바꾸어 쓰십시오.

**curl** — 셸 버전은 상태에 대한 루프입니다.

```
for attempt in 1 2 3; do
  code=$(curl -s -o /tmp/ans.json -w '%{http_code}' -D /tmp/hdr.txt "$URL")
  case "$code" in
    200) cat /tmp/ans.json; exit 0 ;;
    429|503) wait=$(grep -i '^retry-after:' /tmp/hdr.txt | tr -dc 0-9); sleep "${wait:-5}" ;;
    4*)  jq .error /tmp/ans.json; exit 1 ;;               # the fix is in .error.message — do not retry
    *)   sleep $((attempt * 10)) ;;                        # the edge: back off
  esac
done
exit 1
```

**Python**

```
import json, random, time, urllib.error, urllib.parse, urllib.request

BASE = "https://stablescan.achivx.com"
RETRYABLE = {"rate_limited", "timeout", "busy", "snapshot_pending"}

class ScanError(Exception):
    def __init__(self, status, code, message, request_id=None):
        super().__init__(f"{status} {code}: {message} (request {request_id or '?'})")
        self.status, self.code, self.message, self.request_id = status, code, message, request_id

def scan(path, retries=3, **params):
    url = BASE + path + ("?" + urllib.parse.urlencode(params) if params else "")
    for attempt in range(retries + 1):
        req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "my-integration/1.0"})
        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.load(r)                                   # 200 and 202 land here: check "status" on /graph
        except urllib.error.HTTPError as e:
            body = e.read()
            try:
                err = json.loads(body)["error"]
            except (ValueError, KeyError):                            # no envelope: the edge, not the scanner
                err = {"code": "edge_%d" % e.code, "message": body[:120].decode("utf-8", "replace")}
            retry_after = float(e.headers.get("Retry-After") or 0)
            retryable = err["code"] in RETRYABLE or e.code >= 500 and not err["code"].startswith("internal")
            if not retryable or attempt == retries:
                raise ScanError(e.code, err["code"], err["message"], err.get("request_id")) from None
            time.sleep((retry_after or 5 * (attempt + 1)) + random.random())
```

**Go**

```
package scan

import (
	"encoding/json"
	"errors"
	"fmt"
	"math/rand"
	"net/http"
	"net/url"
	"strconv"
	"time"
)

const base = "https://stablescan.achivx.com"

var client = &http.Client{Timeout: 30 * time.Second}

// Error is the scanner's one envelope, plus the HTTP status it came with.
type Error struct {
	Status    int
	Code      string `json:"code"`
	Message   string `json:"message"`
	RequestID string `json:"request_id"`
}

func (e *Error) Error() string {
	return fmt.Sprintf("%d %s: %s (request %s)", e.Status, e.Code, e.Message, e.RequestID)
}

var retryable = map[string]bool{"rate_limited": true, "timeout": true, "busy": true, "snapshot_pending": true}

// Get decodes one answer into out, retrying 429/timeout/busy per Retry-After up to retries times.
func Get(path string, params url.Values, out any, retries int) error {
	u := base + path
	if len(params) > 0 {
		u += "?" + params.Encode()
	}
	for attempt := 0; ; attempt++ {
		req, err := http.NewRequest(http.MethodGet, u, nil)
		if err != nil {
			return err
		}
		req.Header.Set("Accept", "application/json")
		req.Header.Set("User-Agent", "my-integration/1.0")
		res, err := client.Do(req)
		if err != nil {
			return err
		}
		if res.StatusCode < 400 {
			err = json.NewDecoder(res.Body).Decode(out)
			res.Body.Close()
			return err
		}
		var env struct {
			Error Error `json:"error"`
		}
		decodeErr := json.NewDecoder(res.Body).Decode(&env)
		res.Body.Close()
		env.Error.Status = res.StatusCode
		if decodeErr != nil || env.Error.Code == "" { // no envelope: the edge, not the scanner
			env.Error.Code = "edge"
			env.Error.Message = http.StatusText(res.StatusCode)
		}
		again := retryable[env.Error.Code] || (res.StatusCode >= 500 && env.Error.Code != "internal")
		if !again || attempt == retries {
			return &env.Error
		}
		wait, _ := strconv.Atoi(res.Header.Get("Retry-After"))
		if wait == 0 {
			wait = 5 * (attempt + 1)
		}
		time.Sleep(time.Duration(wait)*time.Second + time.Duration(rand.Intn(1000))*time.Millisecond)
	}
}

// IsCode reports whether err is the scanner's envelope with that code.
func IsCode(err error, code string) bool {
	var e *Error
	return errors.As(err, &e) && e.Code == code
}
```

**TypeScript**

```
const BASE = "https://stablescan.achivx.com";
const RETRYABLE = new Set(["rate_limited", "timeout", "busy", "snapshot_pending"]);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export class ScanError extends Error {
  constructor(public status: number, public code: string, message: string, public requestId?: string) {
    super(`${status} ${code}: ${message} (request ${requestId ?? "?"})`);
  }
}

export async function scan<T = unknown>(path: string, params: Record<string, string | number | boolean | undefined> = {}, retries = 3): Promise<T> {
  const qs = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, String(v));
  const url = `${BASE}${path}${qs.toString() ? `?${qs}` : ""}`;
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { accept: "application/json", "user-agent": "my-integration/1.0" } });
    if (res.ok) return (await res.json()) as T;                       // 200 and 202: check `status` on /graph
    const body = (await res.json().catch(() => null)) as { error?: { code: string; message: string; request_id?: string } } | null;
    const err = body?.error ?? { code: `edge_${res.status}`, message: res.statusText };   // no envelope: the edge
    const again = RETRYABLE.has(err.code) || (res.status >= 500 && err.code !== "internal");
    if (!again || attempt === retries) throw new ScanError(res.status, err.code, err.message, err.request_id);
    const retryAfter = Number(res.headers.get("retry-after")) || 5 * (attempt + 1);
    await sleep(retryAfter * 1000 + Math.random() * 1000);
  }
}
```

## 호출하는 쪽에서 오류 읽기

```
try:
    risk = scan(f"/v1/addresses/{addr}/risk", chain=chain)
except ScanError as e:
    if e.code == "invalid_address":
        reject(addr, e.message)                 # the user's input; the message names the problem
    elif e.code in ("timeout", "busy"):
        enqueue_for_later(addr, chain)          # the retries ran out; it is the address's neighbourhood, not you
    else:
        log.error("scanner %s %s", e.code, e.request_id); raise
```

클라이언트를 정직하게 유지하는 세 가지 습관이 있습니다. `400`은 여러분의 버그이거나 사용자의 입력이므로 `message`를 보여 주십시오. 주소 판정에서의 `503 timeout`은 그 주소의 이웃 때문이므로 반복하지 말고 큐에 넣으십시오. 그리고 재시도 후의 오류는 빈 응답이 아닙니다. 확인할 수 없었던 거래 상대는 `unknown`이며, 그것을 어떻게 다룰지는 [응답 읽기](https://achivx.kr/academy/api/reading-an-answer/)에 있습니다.

## 요청 ID

모든 응답에는 `X-Request-Id`가 있습니다. 직접 보내면 그대로 돌려받습니다(엣지 캐시된 조회에서는 대신 Cloudflare ray입니다). 오류 형식은 이를 `request_id`로 반복합니다. 모든 오류와 함께 로그에 남기고, 오류를 보고할 때 함께 알려 주십시오.

## Stablecoin scanner ACHIVX에서

- 카드의 `rateLimits`에 있는 한도: [/.well-known/agent-card.json](https://stablescan.achivx.com/.well-known/agent-card.json); 속도 조절 규칙: [/agents.md](https://stablescan.achivx.com/agents.md) §6.
- `Error` 스키마의 모든 코드: [/openapi.json](https://stablescan.achivx.com/openapi.json); 살펴볼 수 있는 `400`: [/v1/transfers?chain=1&limit=500](https://stablescan.achivx.com/v1/transfers?chain=1&limit=500).

## 출처

- [에이전트 매뉴얼 — agents.md(버전 2026-10-05)](https://stablescan.achivx.com/agents.md)
- [REST 인터페이스의 OpenAPI 3.1 명세(API 1.4.0)](https://stablescan.achivx.com/openapi.json)
- [A2A 에이전트 카드(1.4.0) — 요청 한도와 스키마](https://stablescan.achivx.com/.well-known/agent-card.json)

## 더 알아보기

- [한도는 무엇이며, 거부 응답은 어떤 모습입니까?](https://achivx.kr/academy/for-agents/what-are-the-limits-and-refusals/)
- [응답은 어떤 것이 될 수 있습니까? REST, A2A, MCP의 모든 상태와 코드](https://achivx.kr/academy/for-agents/every-answer-code-on-every-door/)
- [한도](https://achivx.kr/academy/reference/limits/)

## 관련 질문

- [코드에서 피드를 페이지 단위로 넘기고 응답을 캐시하려면 어떻게 합니까?](https://achivx.kr/academy/api/paging-and-caching-in-code/)
- [응답은 어떻게 읽습니까: unknown, null, 빈 목록, no-store](https://achivx.kr/academy/api/reading-an-answer/)
- [API에 첫 요청은 어떻게 보냅니까?](https://achivx.kr/academy/api/first-request/)

오류, 한도, 신뢰 · 400 · 429 · 503 · Retry-After · 2026-10-24 작성, openapi.json, agents.md 및 당일 실제 호출 기준. 응답 형태는 인용이며, 수치는 날짜 기준이고 인덱스에 따라 달라집니다.

---

출처: https://achivx.kr/academy/api/errors-and-limits-in-code/ · 영어 원문: https://achivx.com/academy/api/errors-and-limits-in-code.html
