diff --git a/CHANGELOG.md b/CHANGELOG.md index 29742b9..faec4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Thi - `openmail inbox create` and `openmail init` accept `--domain ` to create an inbox on a verified custom domain you own. When omitted, the inbox uses your account default domain. - `openmail usage` — per-inbox usage (inbound/outbound counts, attachment bytes, stored bytes) plus account totals, with `--from`, `--to`, and `--group-by inbox|account` flags. +- `ApiError` now surfaces rate-limit info (`X-RateLimit-Limit/Remaining/Reset`, `Retry-After`) via a `rateLimit` field, and 429s print a "retry after Ns" hint. --- diff --git a/src/index.ts b/src/index.ts index 0920899..938e5c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -227,7 +227,15 @@ main().catch((err: unknown) => { const parsed = parseArgs(process.argv.slice(2)); const ctx = ctxFromConfig(resolveGlobalConfig(parsed)); if (err instanceof ApiError) { - logError(ctx, err.message, { status: err.status, body: err.body }); + const payload: Record = { status: err.status, body: err.body }; + if (err.rateLimit) { + payload.rateLimit = err.rateLimit; + } + let message = err.message; + if (err.status === 429 && err.rateLimit?.retryAfter !== undefined) { + message = `${err.message} — rate limited, retry after ${err.rateLimit.retryAfter}s`; + } + logError(ctx, message, payload); process.exitCode = 1; return; } diff --git a/src/lib/__tests__/http.test.ts b/src/lib/__tests__/http.test.ts new file mode 100644 index 0000000..770fb6b --- /dev/null +++ b/src/lib/__tests__/http.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { ApiError, OpenMailHttpClient } from "../http"; + +const client = new OpenMailHttpClient({ + baseUrl: "https://api.test", + apiKey: "key", +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("OpenMailHttpClient error handling", () => { + it("attaches rate-limit info from headers on a 429", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "rate_limit_exceeded", scope: "burst" }), { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "42", + "x-ratelimit-limit": "10", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1780000000", + }, + }), + ), + ); + + const err = await client.get("/v1/inboxes").catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(429); + expect(err.rateLimit).toEqual({ + limit: 10, + remaining: 0, + reset: 1780000000, + retryAfter: 42, + }); + }); + + it("leaves rateLimit undefined when no rate-limit headers are present", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "not_found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }), + ), + ); + + const err = await client.get("/v1/inboxes/x").catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.rateLimit).toBeUndefined(); + }); +}); diff --git a/src/lib/http.ts b/src/lib/http.ts index 5868b0c..8fedd70 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -5,17 +5,43 @@ export type HttpClientConfig = { apiKey: string; }; +export type RateLimitInfo = { + limit?: number; + remaining?: number; + reset?: number; + retryAfter?: number; +}; + export class ApiError extends Error { status: number; body: unknown; + rateLimit?: RateLimitInfo; - constructor(message: string, status: number, body: unknown) { + constructor(message: string, status: number, body: unknown, rateLimit?: RateLimitInfo) { super(message); this.status = status; this.body = body; + this.rateLimit = rateLimit; } } +function parseHeaderInt(value: string | null): number | undefined { + if (value === null) return undefined; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? n : undefined; +} + +function extractRateLimit(headers: Headers): RateLimitInfo | undefined { + const info: RateLimitInfo = { + limit: parseHeaderInt(headers.get("x-ratelimit-limit")), + remaining: parseHeaderInt(headers.get("x-ratelimit-remaining")), + reset: parseHeaderInt(headers.get("x-ratelimit-reset")), + retryAfter: parseHeaderInt(headers.get("retry-after")), + }; + const hasAny = Object.values(info).some((v) => v !== undefined); + return hasAny ? info : undefined; +} + export class OpenMailHttpClient { private readonly baseUrl: string; private readonly apiKey: string; @@ -131,6 +157,7 @@ export class OpenMailHttpClient { `OpenMail API error (${response.status})`, response.status, parsedBody ?? text, + extractRateLimit(response.headers), ); } return parsedBody ?? text;