멱등성
시간 초과 후 중복 생성이나 중복 결제 없이 안전하게 재시도하는 방법입니다.
멱등성 키는 ‘주문 8814의 영상 렌더링’처럼 논리적으로 하나인 작업에 붙이는 식별자입니다. 같은 식별자가 붙은 요청이 두 번 도착해도 작업은 한 번만 실행합니다.
createTask는 Idempotency-Key 헤더로 키를 받습니다. 같은 계정 및 같은 API 키에서 같은 키로 두 번 제출하면 첫 번째에 생성된 작업을 반환합니다. 대기 작업, 선점 금액과 과금이 추가되지 않습니다.
POST /api/v1/jobs/createTask
Idempotency-Key: order-8814-render-1키는 보낸 내용인 모델, 전체 input과 callBackUrl에 연결됩니다. 필드 순서나 공백은 상관없지만 실제 내용 변경은 다릅니다. 같은 키로 다른 요청을 보내면 409와 Idempotency-Key has already been used for a different request를 반환합니다. 이 409는 반복해도 성공하지 않습니다. 요청이 실제로 바뀌었다면 새 작업이므로 새 키가 필요합니다.
필요한 이유
네트워크 시간 초과가 발생하면 요청이 아예 도착하지 않았는지, 요청은 도착했지만 응답만 유실되었는지 알 수 없습니다. 멱등성 키가 없으면 재시도를 포기해 이용자를 헛되이 기다리게 하거나, 재시도하여 생성과 결제를 두 번 할 위험을 감수해야 합니다.
키가 있으면 안전하게 재시도할 수 있습니다. 작업이 처음 생성되거나, 조금 전에 생성된 작업을 반환받습니다.
헤더를 사용하는 엔드포인트
| 엔드포인트 | 같은 키로 반복했을 때 |
|---|---|
POST /api/v1/jobs/createTask | 원래 작업의 현재 상태를 반환합니다. |
POST /api/v1/jobs/retry | 해당 재시도로 생성된 작업을 반환합니다. 키 범위가 원본 작업이므로 다른 원본 작업에 같은 키를 써도 충돌하지 않습니다. |
POST /v1/videos | 원래 작업을 반환합니다. |
POST /api/v1/jobs/stream 및 /v1·/v1beta 텍스트 엔드포인트 | 400을 반환합니다. 아래 설명을 참고하세요. |
조회 엔드포인트와 업로드·다운로드 링크 발급 엔드포인트는 헤더를 무시합니다. POST /api/v1/jobs/purge는 같은 taskId에 반복해도 문제가 없으므로 키가 필요하지 않습니다.
텍스트·스트리밍의 키는 중복 과금을 막지만 답변 유실은 복구하지 않습니다
텍스트 요청의 답변은 생성되는 즉시 전송되며 저장되지 않습니다. 완료된 요청의 키와 같은 본문으로 반복하면 400 (This Idempotency-Key was already used for a completed request, and its answer cannot be replayed. Use a new Idempotency-Key to send a new request)을 반환하고 추가 과금하지 않습니다. 텍스트를 다른 곳에서 가져올 수는 없습니다. 이전 요청이 실패했다면 반복 요청도 같은 오류를 반환하므로 새 키로 다시 시도할지 결정할 수 있습니다. 본문이 다르면 409입니다.
따라서 이 엔드포인트에서는 새 메시지마다 새 키를 사용하세요. 네트워크 오류 후 정확히 같은 요청을 재전송할 때만 기존 키를 사용하며, 답변을 잃을 수 있다는 점을 감안해야 합니다. 답변을 다시 받으려면 새 키로 요청하고 비용을 지불하세요. 텍스트와 스트리밍을 참고하세요.
유효 기간은 24시간
키는 처음 승인된 시점부터 24시간 동안 유효합니다. 반복 요청은 기간을 연장하지 않습니다. 그 이후 같은 키는 새 요청으로 취급되어 새 과금 대상 작업을 생성하므로 훨씬 전에 자동 재시도를 중단하세요.
24시간은 절충한 기간입니다. 클라이언트 재시도는 수초나 수분 안에 끝나므로 그 시간을 포함하면 충분합니다. 반면 기간이 길수록 키를 재사용할 수 없는 시간도 길어집니다. 키를 하드코딩하면 하루에 작업 하나만 제출할 수 있게 됩니다.
키 선택
- HTTP 호출마다가 아니라 논리적 요청 하나당 키 하나를 사용하세요. 재시도에 같은 키를 쓰지 않으면 멱등성의 의미가 없습니다.
- 이미 가진 식별자에서 만드세요.
order-8814-render-1은 프로세스 재시작 후에도 다시 계산할 수 있어 무작위 UUID보다 유용합니다. - 기준 식별자가 없다면 첫 시도 전에 UUID를 생성하여 저장하세요. 예를 들어 데이터베이스의 해당 주문 옆에 저장하고 재시도는 그 값을 읽습니다.
- 키는 계정 내에서 선점되므로 전역적으로 고유할 필요는 없습니다. 단, 하나의 논리적 요청을 재시도할 때는 같은 API 키를 써야 합니다. 최대 길이는 128자입니다.
같은 계정의 다른 API 키로는 재생할 수 없습니다
멱등성 매핑은 계정 전체에 적용되지만 작업 접근은 API 키별로 분리됩니다. 같은 계정의 다른 키가 이미 선점된 멱등성 키를 사용하면 409 Conflict (Idempotency-Key is already in use by this account)를 받으며 응답은 원래 작업 ID를 공개하지 않습니다. 다른 통합의 작업을 반복 요청으로 탐색하지 못하도록 하는 장치입니다. 재시도해도 해결되지 않으므로 첫 요청에 사용한 API 키로 보내세요.
재시도 루프 안에서 키를 생성하지 마세요
// Wrong: a new key per attempt, which defeats the whole mechanism
for (let i = 0; i < 3; i++) {
await create({ 'Idempotency-Key': crypto.randomUUID() });
}
// Right: generate once, outside the loop
const key = crypto.randomUUID();
for (let i = 0; i < 3; i++) {
await create({ 'Idempotency-Key': key });
}반복 요청의 응답
createTask에서는 같은 taskId와 estimatedCost를 반환하지만 state는 원래 작업의 현재 상태입니다. queued, running 또는 종료 상태일 수 있습니다. 반복 요청으로 두 번째 작업, 선점이나 과금은 발생하지 않습니다.
반복 요청임을 알려 주는 별도 헤더나 필드는 없습니다. 전체 작업 기록은 taskId를 recordInfo에 전달하여 읽으세요.
같은 키를 동시에 제출한 경우
같은 API 키와 멱등성 키의 요청 두 개가 동시에 도착하면 정확히 하나만 작업을 생성하고 다른 요청도 같은 taskId를 받습니다. 존재 여부 조회 후 삽입하는 방식이 아니라 데이터베이스 고유 제약으로 결정합니다. 단순 조회 방식은 동시 요청 둘 다 비어 있다고 판단하여 작업과 선점이 두 번 발생할 수 있습니다.
드물게 경합 상황에서 다음 응답이 발생합니다.
{ "code": 409, "msg": "Idempotency key conflict, please retry shortly" }수백 밀리초 기다린 뒤 같은 키로 재시도하세요.
createTask의 다른 두 409는 재시도해도 해결되지 않습니다. Idempotency-Key has already been used for a different request는 요청 내용을 바꾼 경우, Idempotency-Key is already in use by this account는 같은 계정의 다른 API 키가 이미 사용한 경우입니다. 세 가지 모두 code: 409이고 msg를 기준으로 분기하면 안 되므로, 409 재시도를 한두 번으로 제한한 뒤 사람이 확인할 수 있도록 오류를 표시하세요.
전체 예시
함수는 키를 인수로 받습니다. 프로세스를 재시작해도 같은 키를 보내도록 자체 기록(여기서는 주문 ID)에서 계산하세요.
// These are business codes from the response body, not HTTP statuses.
const RETRYABLE = new Set([429, 500, 50301]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (attempt) => sleep(2 ** attempt * 500 + Math.random() * 300);
async function createTaskWithRetry(payload, idempotencyKey, { attempts = 4 } = {}) {
let conflicts = 0;
for (let i = 0; i < attempts; i++) {
// Declared outside try so the checks below can read them.
let res;
let body;
try {
res = await fetch('https://api.spicyapi.ai/api/v1/jobs/createTask', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPICY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(payload),
});
body = await res.json();
} catch {
// Transport failure or unreadable body: the request may well have landed.
// Retrying with the same key is safe.
await backoff(i);
continue;
}
if (res.status === 202 && body.code === 200) return body.data;
// Only one of the three 409s is a transient race, so cap 409 retries at one.
const retry = body.code === 409 ? ++conflicts <= 1 : RETRYABLE.has(body.code);
if (!retry) {
throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
}
await backoff(i);
}
// Still unknown whether the task exists. Try again later with the SAME key.
throw new Error(`out of retries for Idempotency-Key ${idempotencyKey}`);
}
// payload is { model, input } built from the live catalog; order is your own record.
// The key is derived from the order, so it is identical after a restart.
const task = await createTaskWithRetry(payload, `order-${order.id}-render-1`);
console.log(task.taskId);import os, random, time, requests
# These are business codes from the response body, not HTTP statuses.
RETRYABLE = {429, 500, 50301}
def create_task_with_retry(payload: dict, idempotency_key: str, attempts: int = 4) -> dict:
conflicts = 0
for i in range(attempts):
try:
res = requests.post(
"https://api.spicyapi.ai/api/v1/jobs/createTask",
headers={
"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
json=payload,
timeout=30,
)
body = res.json()
except (requests.RequestException, ValueError):
# Transport failure or unreadable body: the request may well have landed.
# Retrying with the same key is safe.
time.sleep(2**i * 0.5 + random.random() * 0.3)
continue
if res.status_code == 202 and body["code"] == 200:
return body["data"]
# Only one of the three 409s is a transient race, so cap 409 retries at one.
if body["code"] == 409:
conflicts += 1
retry = conflicts <= 1
else:
retry = body["code"] in RETRYABLE
if not retry:
raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
time.sleep(2**i * 0.5 + random.random() * 0.3)
# Still unknown whether the task exists. Try again later with the SAME key.
raise RuntimeError(f"out of retries for Idempotency-Key {idempotency_key}")
# payload is {"model": ..., "input": ...} built from the live catalog; order_id is yours.
# The key is derived from the order, so it is identical after a restart.
task = create_task_with_retry(payload, f"order-{order_id}-render-1")
print(task["taskId"])전송 오류에도 같은 키를 사용하세요
fetch 예외, 연결 시간 초과, 읽기 시간 초과가 발생하면 모두 요청이 도착했는지 알 수 없습니다. 바로 이때 멱등성 키가 필요합니다. 같은 키로 재시도하면 최악의 경우에도 이전 작업을 반환받습니다. 시도 횟수가 소진되더라도 키를 보관하세요. 24시간 안에 나중에 다시 호출하면 같은 작업을 반환합니다.

