멱등성과 재시도
멱등 키로 중복 발송·중복 차감 없이 발급을 재시도하는 방법
네트워크 타임아웃이나 일시 오류가 나면 같은 발급 요청을 다시 보내야 할 때가 있습니다. K-OTP는 멱등 키(idempotency key) 로 같은 요청을 한 번만 처리해, 재시도해도 문자가 두 번 가거나 크레딧이 두 번 차감되지 않도록 합니다.
멱등 키는 필수입니다
POST /v1/issue는 멱등 키 없이는 호출할 수 없습니다. 다음 중 하나로 보냅니다.
Idempotency-Key요청 헤더- 요청 본문의
idempotencyKey필드
| 규칙 | 결과 |
|---|---|
| 헤더와 본문 모두 없음, 또는 공백 | 400 BAD_REQUEST |
| 헤더와 본문을 모두 보냈는데 값이 다름 | 400 BAD_REQUEST |
| 형식: 앞뒤 공백 제거 후 1–128자의 공백 없는 출력 가능한 ASCII 문자 | 위반 시 400 BAD_REQUEST |
키는 "사용자가 인증번호를 한 번 요청한 행위" 하나마다 새로 만듭니다. UUID(crypto.randomUUID())가 가장 간단합니다.
같은 키로 다시 보내면
| 상황 | 응답 |
|---|---|
| 같은 키 + 같은 요청 본문 | 최초 응답을 그대로 돌려줍니다(200). 다시 발송하거나 다시 차감하지 않습니다. |
| 같은 키 + 다른 요청 본문 | 409 CONFLICT |
같은 키인데, 그 키로 만든 issue가 이후 새 발급으로 교체(replaced)됨 | 409 CONFLICT |
| 같은 키로 보낸 이전 요청의 처리 결과가 아직 확정되지 않음 | 503 SERVICE_UNAVAILABLE — 잠시 후 같은 키로 다시 시도하세요. |
409를 받았다면 재시도로 해결되지 않습니다. 요청 본문이 달라졌는지 확인하거나, 정말 새 발급이 필요하다면 새 키를 만드세요.
결과가 불명확할 때: 반드시 같은 키로 재시도
타임아웃, 연결 끊김, 503처럼 첫 요청이 처리됐는지 알 수 없는 경우에는 반드시 최초의 멱등 키를 그대로 다시 사용해야 합니다.
결과가 불명확한 요청을 새 멱등 키로 다시 보내지 마세요. 첫 요청의 문자가 이미 나갔을 수 있으므로, 새 키는 중복 발송과 중복 차감을 일으킬 수 있습니다.
K-OTP는 문자 발송사의 응답이 불명확한 경우(예: 발송사 타임아웃) 이를 ambiguous로 기록하고 서버에서 자동으로 재발송하지 않습니다. 이런 발송은 GET /v1/status 응답의 providerOutcomeAmbiguous: true로 확인할 수 있습니다. 사용자가 문자를 받지 못했다면, 사용자의 명시적인 "재전송" 요청에 따라 새 키로 새 발급을 하세요(이전 issue는 replaced가 됩니다).
재시도 예제
const API = "https://api.k-otp.dev/v1";
class NonRetryableError extends Error {}
/** 같은 멱등 키로만 재시도합니다. 타임아웃·네트워크 오류·500·503만 재시도 대상입니다. */
export async function issueWithRetry(
input: { phoneNumber: string; purpose: string },
idempotencyKey: string = crypto.randomUUID(),
maxAttempts = 4,
) {
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch(`${API}/issue`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KOTP_SECRET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // 모든 시도에서 동일
},
body: JSON.stringify(input), // 모든 시도에서 동일
signal: AbortSignal.timeout(10_000),
});
const body = await res.json();
if (res.ok)
return body as {
issueId: string;
expiresAt: string;
attemptsRemaining: number;
queuedAt: string;
};
if (res.status !== 500 && res.status !== 503) {
// 400, 401, 402, 403, 409는 재시도해도 결과가 같습니다.
throw new NonRetryableError(`${body.code}: ${body.message}`);
}
} catch (error) {
if (error instanceof NonRetryableError) throw error;
// 타임아웃·네트워크 오류: 처리 여부를 알 수 없으므로 같은 키로 재시도합니다.
}
if (attempt >= maxAttempts) {
throw new Error(`issue outcome unknown; retry later with the same key: ${idempotencyKey}`);
}
await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
}
}재시도가 모두 실패했다면 멱등 키를 버리지 말고 저장해 두었다가, 나중에 같은 키로 다시 시도하세요.
재발급과 교체
사용자가 "인증번호 다시 받기"를 누른 것처럼 의도적으로 새 인증번호가 필요할 때는 새 멱등 키로 POST /v1/issue를 호출합니다. 같은 전화번호와 같은 purpose로 새로 발급하면, 이전의 아직 검증되지 않은 issue는 즉시 replaced 상태가 되어 더 이상 검증할 수 없습니다. 새 issueId로만 검증하세요.
검증 요청
POST /v1/verify에는 멱등 키가 없습니다. 검증은 1회성이므로 이미 성공한 issue를 다시 검증하면 verified: false, reasonCode: "ALREADY_VERIFIED"가 반환됩니다. 검증 요청이 타임아웃되어 재시도했을 때 이 응답을 받았다면, 첫 요청이 이미 성공했을 수 있다는 뜻입니다. 필요하면 sk_ 키로 GET /v1/status의 verificationStatus를 확인하세요.