LinkHarbor API가 분당 허용하는 요청 수, 요청의 최대 크기, 헤더를 읽는 방법, 429를 올바르게 처리하는 방법을 설명합니다.
모두에게 빠른 API를 유지하기 위해 LinkHarbor는 분당 요청 수를 제한합니다. 한도는 플랜과 관계없으며 월간 API 호출 할당량은 없습니다. 이 글에서는 모든 한도를 정리하고 코드에서 깔끔하게 처리하는 방법을 보여 줍니다.
요청을 세는 방법
- 모든 요청은 정확히 하나의 버킷에 들어감. 예를 들어 인증된 요청은 user, 토큰 교환은 token
- 1분 단위(UTC)로 세며, 새로운 1분이 시작될 때마다 카운터가 0에서 다시 시작됨
- 버킷에 따라 워크스페이스, 사람, 연결, IP 주소 단위로 셈
- 토큰, 스코프, 스키마에서 이미 실패하는 요청(401, 403 insufficient_scope, 400, 스키마 검사의 422 validation_failed)은 세지 않음. 403 feature_not_available 같은 이후 단계의 오류는 셈
모든 한도
| 버킷(X-RateLimit-Bucket) | 한도 | 집계 단위 | 적용 대상 |
|---|---|---|---|
| user | 분당 600회 | API 키의 토큰은 워크스페이스(워크스페이스의 모든 키와 AI 도구 합산), 대시보드 로그인은 사람 | 자체 버킷이 없는 /v1의 모든 인증된 요청 |
| token | 분당 30회 | IP 주소 | POST /v1/token |
| public | 분당 120회 | IP 주소 | GET /v1/plans, GET /v1/pages/available 같은 인증 없는 API 요청 |
| callback | 분당 120회 | IP 주소 | POST /v1/callbacks/sale |
| internal | 분당 120회 | IP 주소 | “Custom (API)” 연동의 Ingest URL(POST /v1/ingest/{id}) |
| mcp | 분당 120회 | AI 연결(액세스 토큰) | /mcp의 MCP 서버 |
| oauth | 분당 300회 | IP 주소 | AI 어시스턴트용 OAuth 로그인과 디스커버리 |
| auth | 분당 30회 | IP 주소 | 로그인, 가입, 비밀번호 재설정(대시보드와 앱) |
| refresh | 분당 120회 | IP 주소 | 대시보드 세션 연장 |
| discover | 분당 60회 | 사람, 로그인하지 않았으면 IP 주소 | 앱의 둘러보기 |
| subs_read | 분당 120회 | 사람, 로그인하지 않았으면 IP 주소 | 팔로우하는 페이지 읽기(앱) |
| subs_write | 분당 20회 | 사람, 로그인하지 않았으면 IP 주소 | 팔로우하는 페이지 변경(앱) |
워크스페이스당 하나의 예산
같은 워크스페이스의 API 키 토큰은 키를 몇 개 만들든 모두 분당 600회를 함께 씁니다. 연결된 AI 어시스턴트의 도구도 내부적으로 API를 호출하므로 여기에 포함됩니다. 따라서 키를 늘려도 한도는 올라가지 않습니다. 부하가 여러 프로그램에 분산된다면 공유 예산을 기준으로 계획하세요.
헤더
- X-RateLimit-Limit
- 버킷의 분당 한도입니다. 예: 600.
- X-RateLimit-Remaining
- 현재 1분 동안 남은 요청 수입니다.
- X-RateLimit-Bucket
- 요청이 들어간 버킷입니다. 예: user.
- Retry-After
- 429 rate_limited에서만 있습니다: 다음 1분이 시작될 때까지의 초입니다. api_key_locked에서는 대기 시간이 details.retry_after_sec에 있습니다.
한도에 도달하면
한도에 도달하면 API가 HTTP 429와 다음 본문으로 응답합니다. message의 텍스트는 계정 언어로 표시되며(API 오류 형식 참고), 여기서는 영어로 보여 줍니다. 코드 rate_limited를 기준으로 처리하세요.
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Bucket: user
{ "error": "rate_limited", "message": "Too many requests. Please try again in a moment.", "retry_after_sec": 37 } - Retry-After 또는 retry_after_sec를 읽습니다
- 최소 그만큼의 초를 기다리고, 모든 프로세스가 같은 순간에 다시 시작하지 않도록 약간의 무작위 시간을 더합니다
- 요청은 최대 몇 번만 다시 시도합니다
- 계속 429를 받으면 작업을 더 긴 시간에 걸쳐 나눕니다
const BASE = 'https://dock.at-harbor.com';
let cached = null; // { token, expiresAt }
async function getToken() {
if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
const res = await fetch(BASE + '/v1/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key_id: process.env.LH_KEY_ID,
key_secret: process.env.LH_KEY_SECRET,
}),
});
if (!res.ok) throw new Error('Token exchange failed: HTTP ' + res.status);
const data = await res.json();
cached = { token: data.token, expiresAt: Date.now() + data.expires_in * 1000 };
return cached.token;
}
async function lh(method, path, body, attempt = 0) {
const headers = { Authorization: 'Bearer ' + (await getToken()) };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429 && attempt < 3) {
const wait = Number(res.headers.get('Retry-After') ?? 5);
await new Promise((r) => setTimeout(r, (wait + Math.random() * 2) * 1000));
return lh(method, path, body, attempt + 1);
}
if (res.status === 401 && attempt === 0) {
cached = null; // 토큰 만료 – 새 토큰으로 한 번 더 시도
return lh(method, path, body, attempt + 1);
}
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error((data && data.error) + ' (HTTP ' + res.status + ')');
return data;
}
// 예: const { items } = await lh('GET', '/v1/pages'); 대량 데이터를 위한 팁
- 토큰을 캐시하고 15분 만료 직전에만 갱신
- X-RateLimit-Remaining을 보고 적어지면 속도 줄이기
- 수십 개의 병렬 연결 대신 요청을 차례로 보내기
- 페이지마다 따로 조회하지 말고 목록을 한 번 불러와 로컬에서 거르기
- 큰 동기화는 몇 분에 걸쳐 나누기
- 5xx 오류는 간격을 늘려 가며 다시 시도하되 4xx 오류는 다시 시도하지 않기
기타 한도
| 대상 | 한도 | 응답 |
|---|---|---|
| 로그인 실패 | 이메일 주소와 IP별로 5, 10, 20번 실패하면 잠금: 60초, 5분, 30분 | 429 account_locked |
| 폼 제출 | 모든 폼을 합쳐 IP 주소당 시간당 10건 | 429 form_rate_limited |
토큰 교환에 실패하면 키와 IP가 일시적으로 잠깁니다. API 키와 토큰을 참고하세요. 푸시 알림은 대시보드에서만 보내며, 그 한도는 팔로워와 푸시 알림에서 설명합니다.
페이지, 링크, 블록, 단축 링크 같은 플랜의 콘텐츠 한도는 요청 한도가 아니라 고정된 상한입니다. 플랜 비교에 정리되어 있습니다.
요청 크기
API는 본문을 읽기 전에 크기를 잽니다. 허용되는 크기는 엔드포인트에 따라 다릅니다.
| 엔드포인트 | 최대 본문 |
|---|---|
| 로그인, 가입 등 /v1/auth 아래의 인증 없는 호출, POST /v1/token, POST /v1/oauth/exchange, /oauth 아래의 AI 어시스턴트용 OAuth 엔드포인트와 그 동의, POST /v1/ingest/{id}, 후원하기 블록 결제, 앱의 구독 호출(팔로우, 컬렉션, 푸시 기기), 워크스페이스 전환, POST /v1/auth/logout-all, 계정 삭제, 2단계 인증 설정, 초대 수락, 추천 프로그램 활성화, 연동 연결 시의 복귀 단계 | 64 KB |
| 페이지에서 폼 제출을 포함한 그 밖의 모든 JSON 엔드포인트 | 1 MB |
| POST /v1/webhooks/{id}/verify-signature와 /mcp의 MCP 서버 | 2 MB |
| 파일 업로드(POST /v1/assets와 POST /v1/pages/{id}/uploads, multipart/form-data) | 요청 전체 11 MB, 파일 자체는 최대 10 MB |
본문이 더 크면 API가 413 request_too_large로 응답하며, details.max_bytes와 details.max_kb에 한도가 나옵니다. 업로드에서 파일이 너무 크면 413 payload_too_large가 됩니다. DELETE 호출처럼 본문이 없는 엔드포인트는 본문을 받지 않거나 {}만 받습니다.
HTTP/1.1 413 Payload Too Large
{ "error": "request_too_large", "message": "…", "details": { "max_bytes": 65536, "max_kb": 64 }, "request_id": "…" } 본문 안에도 한도가 있습니다. 모든 텍스트에는 최대 길이가, 모든 목록에는 최대 항목 수가 있으며, 알 수 없는 필드는 거부됩니다. 값은 API 레퍼런스에 엔드포인트별로 나와 있습니다. 위반하면 422 validation_failed가 되며, details에 필드, 규칙, 한도가 나옵니다. 요청의 경로와 쿼리 문자열은 합쳐서 최대 16 KB입니다.
함께 보면 좋은 글
원하는 내용을 찾지 못했나요?
필요한 내용을 알려 주시면 지원팀이 직접 도와드립니다.