Errors
멱등한 호출의 재시도 규칙과 backoff 동작을 설명합니다.
실패한 호출을 무작정 다시 보내면 예약이 두 번 생기거나 rate limit이 더 깊어질 수 있습니다. 재시도는 세 가지 질문으로 정리됩니다. 이 호출이 멱등한가, 얼마나 기다렸다 다시 보낼 것인가, 이 에러가 재시도로 해결되는 종류인가. 마지막 질문에는 에러 모델의 envelope가 직접 답을 줍니다.
GET과 DELETE는 멱등합니다. 같은 요청을 몇 번 보내도 결과가 달라지지 않으므로 네트워크 오류나 502 이후 안심하고 재시도할 수 있습니다. POST는 기본적으로 멱등하지 않습니다. 응답을 받지 못한 Create reservation 호출을 그대로 다시 보내면 예약이 두 건 생길 수 있습니다.
| 메서드 | 멱등성 | 재시도 |
|---|---|---|
| GET | 멱등 | 안전합니다. backoff와 jitter로 재시도합니다. |
| DELETE | 멱등 | 안전합니다. 같은 취소를 반복해도 결과가 달라지지 않습니다. |
| POST | 비멱등 | 상태를 만드는 POST는 Idempotency-Key 헤더가 있을 때만 안전합니다. Estimate ride처럼 상태를 바꾸지 않는 POST는 레퍼런스 설명에 명시되어 있고 그대로 재시도해도 안전합니다. |
POST를 재시도 가능하게 만들려면 첫 시도부터 Idempotency-Key 헤더를 보냅니다. 게이트웨이가 키를 24시간 동안 기억하고, 같은 키로 다시 도착한 요청에는 첫 처리의 결과를 그대로 반환합니다. Create reservation과 Join waitlist처럼 상태를 만드는 모든 POST가 이 헤더를 받습니다.
curl -X POST "https://api.frism.dev/v1/catch-table/reservations" \
-H "Authorization: Bearer frism_test_k3xample" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: idem_b1f7c2d0" \
-d '{
"restaurant_id": "res_01J8Q9W3TE",
"slot_id": "slt_1900_2",
"party_size": 2,
"contact_name": "Han Jiwoo"
}'재시도 간격은 지수적으로 늘리고, 매 간격에 무작위 jitter를 더합니다. 모든 클라이언트가 고정 간격으로 재시도하면 장애에서 복구 중인 서비스에 요청이 동시에 몰려 복구가 늦어집니다. 예외가 하나 있습니다. 429 응답에는 Retry-After 헤더가 포함되며, 이 값이 있으면 계산한 backoff 대신 헤더를 따릅니다.
시도 횟수에는 상한을 둡니다. 서너 번의 재시도로 성공하지 못한 요청은 대기열에 더 잡아두지 말고 에러를 그대로 반환해, 호출한 쪽이 사용자에게 알리거나 나중에 다시 시도하도록 넘깁니다. 상한 없이 재시도를 반복하는 클라이언트는 장애를 연장시킬 뿐입니다.
envelope의 retryable 필드는 게이트웨이가 판단한 재시도 가능 여부입니다. 429 rate_limited와 502 provider_unavailable에서 true입니다. status 목록을 클라이언트에 하드코딩하는 대신 이 플래그로 분기하면, 매핑이 확장되어도 코드를 고칠 필요가 없습니다.
아래 helper가 위 규칙 전부를 구현합니다. 재시도 대상은 네트워크 오류와 429, 502뿐이고, Retry-After가 있으면 그 값을 우선하며, 없으면 full jitter backoff를 사용합니다.
const BASE_URL = "https://api.frism.dev/v1";
const RETRYABLE_STATUSES = new Set([429, 502]);
interface FrismRequest {
method?: "GET" | "POST" | "DELETE";
headers?: Record<string, string>;
body?: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// full jitter: 0과 지수 상한 사이의 무작위 대기.
function backoffMs(attempt: number, baseMs = 500, capMs = 8_000): number {
const ceiling = Math.min(capMs, baseMs * 2 ** (attempt - 1));
return Math.random() * ceiling;
}
export async function frismFetch(
path: string,
init: FrismRequest = {},
maxAttempts = ,
): Promise<Response> {
for ( attempt = ; ; attempt += ) {
response: Response | undefined;
{
response = fetch(`${BASE_URL}${path}`, {
...init,
headers: {
Authorization: ,
...init.headers,
},
});
} (cause) {
(attempt >= maxAttempts) cause;
}
(response) {
(!RETRYABLE_STATUSES.has(response.status)) response;
(attempt >= maxAttempts) response;
}
retryAfter = Number(response?.headers.get());
delayMs = retryAfter > ? retryAfter * : backoffMs(attempt);
sleep(delayMs);
}
}POST를 통과시킬 때는 Idempotency-Key를 함께 전달합니다.
const response = await frismFetch("/catch-table/reservations", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": "idem_b1f7c2d0",
},
body: JSON.stringify({
restaurant_id: "res_01J8Q9W3TE",
slot_id: "slt_1900_2",
party_size: 2,
contact_name: "Han Jiwoo",
}),
});
if (!response.ok) {
// 여기 도달했다면 재시도로 해결되지 않는 에러입니다. code로 분기합니다.
const { error } = (await response.json()) as { error: { code: string } };
console.error(error.code);
}conflict: 리소스 상태가 바뀌었다는 뜻입니다. 같은 요청을 반복하지 말고, 를 다시 읽은 뒤 다른 슬롯으로 새 요청을 만듭니다.provider_rejected: 서비스 정책이 요청을 거부했습니다. 같은 요청은 몇 번을 보내도 같은 답이 돌아옵니다.각 status와 code의 의미는 에 정리되어 있습니다. 상태 변화를 폴링 대신 푸시로 받으면 재시도할 일 자체가 줄어듭니다. 를 참고합니다.