Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

---

Expand Down
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { 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;
}
Expand Down
57 changes: 57 additions & 0 deletions src/lib/__tests__/http.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
29 changes: 28 additions & 1 deletion src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -131,6 +157,7 @@ export class OpenMailHttpClient {
`OpenMail API error (${response.status})`,
response.status,
parsedBody ?? text,
extractRateLimit(response.headers),
);
}
return parsedBody ?? text;
Expand Down