From a63c36053966e174ce17cb61c403bce3a1e93efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 3 Sep 2026 20:12:49 -0400 Subject: [PATCH] fix: stop replaying non-idempotent requests and expose the rotation grace A retried POST /v1/auth/rotate loses the replacement token because the presented token is already in grace, and a retried webhook registration creates duplicates. Only idempotent methods now retry a 429 or 5xx. Add previous_token_grace_until to RotateApiTokenResponse. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- src/client.ts | 9 ++++++++- src/types.ts | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 47e6a49..52302c8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -344,7 +344,7 @@ export class ElydoraClient { body: body !== undefined ? JSON.stringify(body) : undefined, }); - if (attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) { + if (attempt < this.maxRetries && isIdempotent(method) && (res.status === 429 || res.status >= 500)) { const retryAfter = res.headers.get('Retry-After'); const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1000 @@ -372,6 +372,13 @@ export class ElydoraClient { } } +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); + +/** A lost response to a non-idempotent request must not be replayed. */ +function isIdempotent(method: string): boolean { + return IDEMPOTENT_METHODS.has(method.toUpperCase()); +} + async function handleResponse(res: Response): Promise { if (res.ok) { if (res.status === 204) { diff --git a/src/types.ts b/src/types.ts index f849bc5..113cb66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -384,6 +384,7 @@ export interface RotateApiTokenResponse { readonly token: string; readonly expires_at: number | null; readonly token_id: string; + readonly previous_token_grace_until: number; } export interface HealthResponse {