코드에서 오류, 요청 한도, 타임아웃은 어떻게 처리합니까?
오류, 한도, 신뢰 · 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(일부가 유실됨, 다시 요청)입니다. 모든 접점의 모든 상태와 정확한 메시지는 에이전트용 코드 페이지에 있습니다.
한도
클라이언트마다 토큰 버킷이 하나 있습니다. 분당 요청 1,200건이며, 에이전트 카드의 rateLimits에 공개되어 있고 스킬에서는 초당 20건, 버스트 40건으로 명시합니다. 이것은 작업량이 아니라 요청을 제한합니다. 한도 안의 무거운 조회도 타임아웃될 수 있습니다. MCP는 같은 속도로 별도의 예산을 가집니다. 대량 작업을 초당 몇 건의 속도로 조절하면 429를 보지 않고, 폴러를 각 라우트의 max-age(표)에 맞춰 조절하면 같은 바이트를 두 번 요청하지 않습니다.
이를 수행하는 클라이언트
아래의 각 클라이언트는 첫 요청의 헬퍼를 감쌉니다. 응답을 분류하고, 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 1Python
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이며, 그것을 어떻게 다룰지는 응답 읽기에 있습니다.
요청 ID
모든 응답에는 X-Request-Id가 있습니다. 직접 보내면 그대로 돌려받습니다(엣지 캐시된 조회에서는 대신 Cloudflare ray입니다). 오류 형식은 이를 request_id로 반복합니다. 모든 오류와 함께 로그에 남기고, 오류를 보고할 때 함께 알려 주십시오.
Stablecoin scanner ACHIVX에서
- 카드의
rateLimits에 있는 한도: /.well-known/agent-card.json; 속도 조절 규칙: /agents.md §6. Error스키마의 모든 코드: /openapi.json; 살펴볼 수 있는400: /v1/transfers?chain=1&limit=500.
출처
- 에이전트 매뉴얼 — agents.md(버전 2026-10-05)
- REST 인터페이스의 OpenAPI 3.1 명세(API 1.4.0)
- A2A 에이전트 카드(1.4.0) — 요청 한도와 스키마
더 알아보기
관련 질문
- 코드에서 피드를 페이지 단위로 읽고 응답을 캐시하려면 어떻게 합니까?
- 응답은 어떻게 읽습니까: unknown, null, 빈 목록, no-store
- API에 첫 요청은 어떻게 보냅니까?
오류, 한도, 신뢰 · 400 · 429 · 503 · Retry-After · 2026-10-24 작성, openapi.json, agents.md 및 당일 실제 호출 기준. 응답 형태는 인용이며, 수치는 날짜 기준이고 인덱스에 따라 달라집니다.
이 페이지는 ACHIVX Academy 영어 원문을 한국어로 옮긴 것입니다. 번역과 원문이 다르면 영어 원문이 기준입니다.