K-OTP

멱등성과 재시도

멱등 키로 중복 발송·중복 차감 없이 발급을 재시도하는 방법

네트워크 타임아웃이나 일시 오류가 나면 같은 발급 요청을 다시 보내야 할 때가 있습니다. 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를 확인하세요.

이 페이지의 내용