5 Commits
Author SHA1 Message Date
雷电芽衣 528081bb88 chore: release 2.1.1
2.1.0 was tagged but never published to npm. This release carries the
deep-health 503 handling, the non-idempotent retry guard, and
previous_token_grace_until.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 21:52:39 -04:00
雷电芽衣 6b0e8b8f77 fix: stop retrying non-idempotent requests after a transport error
The status guard covered 429 and 5xx, but the catch branch still replayed
every method, so a consumed registerWebhook or token rotation could run
twice after a lost connection.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 20:22:34 -04:00
雷电芽衣 a63c360539 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
2026-09-03 20:12:49 -04:00
雷电芽衣 89bdd6e3b8 chore: sync the lockfile version with package.json
Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 19:55:44 -04:00
雷电芽衣 909691328e fix: return the degraded deep-health report instead of throwing
The API answers GET /v1/health/deep with 503 and a full report when a
dependency is down. handleResponse treated that as an error, so
deepHealth() could never report a degraded status. The Go SDK already
accepts 503 for this endpoint.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 19:36:24 -04:00
4 changed files with 18 additions and 5 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@elydora/sdk", "name": "@elydora/sdk",
"version": "2.0.1", "version": "2.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@elydora/sdk", "name": "@elydora/sdk",
"version": "2.0.1", "version": "2.1.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@decimalturn/toml-patch": "^2.0.0", "@decimalturn/toml-patch": "^2.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elydora/sdk", "name": "@elydora/sdk",
"version": "2.1.0", "version": "2.1.1",
"description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform", "description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+14 -2
View File
@@ -285,9 +285,13 @@ export class ElydoraClient {
return handleResponse<HealthResponse>(res); return handleResponse<HealthResponse>(res);
} }
/** 503 carries the degraded report, not an error. */
async deepHealth(): Promise<DeepHealthResponse> { async deepHealth(): Promise<DeepHealthResponse> {
const url = `${this.baseUrl}/v1/health/deep`; const url = `${this.baseUrl}/v1/health/deep`;
const res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } }); const res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } });
if (res.status === 503) {
return (await res.json()) as DeepHealthResponse;
}
return handleResponse<DeepHealthResponse>(res); return handleResponse<DeepHealthResponse>(res);
} }
@@ -340,7 +344,7 @@ export class ElydoraClient {
body: body !== undefined ? JSON.stringify(body) : undefined, 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 retryAfter = res.headers.get('Retry-After');
const delayMs = retryAfter const delayMs = retryAfter
? parseInt(retryAfter, 10) * 1000 ? parseInt(retryAfter, 10) * 1000
@@ -357,10 +361,11 @@ export class ElydoraClient {
throw err; throw err;
} }
if (attempt < this.maxRetries) { if (attempt < this.maxRetries && isIdempotent(method)) {
await sleep(Math.min(1000 * 2 ** attempt, 10_000)); await sleep(Math.min(1000 * 2 ** attempt, 10_000));
continue; continue;
} }
break;
} }
} }
@@ -368,6 +373,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<T>(res: Response): Promise<T> { async function handleResponse<T>(res: Response): Promise<T> {
if (res.ok) { if (res.ok) {
if (res.status === 204) { if (res.status === 204) {
+1
View File
@@ -384,6 +384,7 @@ export interface RotateApiTokenResponse {
readonly token: string; readonly token: string;
readonly expires_at: number | null; readonly expires_at: number | null;
readonly token_id: string; readonly token_id: string;
readonly previous_token_grace_until: number;
} }
export interface HealthResponse { export interface HealthResponse {