import { randomUUID } from "node:crypto";
const RETRYABLE = new Set(["rate_limited", "upstream_unavailable"]);
const TERMINAL = new Set(["done", "cancelled", "failed", "qa_failed"]);
export class SigmoraError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown,
readonly requestId?: string | null,
) {
super(`${code}: ${message}`);
}
}
export type Job = {
id: string;
kind: string;
status: "queued" | "rendering" | "awaiting_review" | "done" | "cancelled" | "failed" | "qa_failed";
phase?: string | null;
error?: string | null;
artifacts?: Record<string, unknown> | null;
};
type RequestOptions = {
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
idempotencyKey?: string;
timeoutMs?: number;
};
export class Sigmora {
constructor(
private readonly apiKey = process.env.SIGMORA_API_KEY ?? "",
private readonly baseUrl = "https://api.sigmora.org",
private readonly maxRetries = 4,
) {
if (!apiKey) throw new Error("Set SIGMORA_API_KEY");
}
async request<T = any>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const url = new URL(path, this.baseUrl);
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
// A POST is only safe to retry when it carries an idempotency key.
const idempotencyKey = method === "GET" ? undefined : (options.idempotencyKey ?? randomUUID());
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: AbortSignal.timeout(options.timeoutMs ?? 120_000),
});
const body = await response.json().catch(() => ({}));
if (response.ok) return body as T;
const code: string = body.code ?? "internal_error";
if (RETRYABLE.has(code) && attempt < this.maxRetries) {
const retryAfter = Number(response.headers.get("Retry-After"));
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000 + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
throw new SigmoraError(
response.status,
code,
body.error ?? response.statusText,
body.details,
response.headers.get("x-request-id"),
);
}
}
get<T = any>(path: string, query?: RequestOptions["query"]) {
return this.request<T>("GET", path, { query });
}
post<T = any>(path: string, body: unknown, idempotencyKey?: string, timeoutMs?: number) {
return this.request<T>("POST", path, { body, idempotencyKey, timeoutMs });
}
/** Poll a job until it finishes or needs a person. */
async waitForJob(jobId: string, { intervalMs = 15_000, timeoutMs = 60 * 60_000 } = {}): Promise<Job> {
const deadline = Date.now() + timeoutMs;
while (true) {
const { job } = await this.get<{ job: Job }>(`/v1/jobs/${encodeURIComponent(jobId)}`);
if (TERMINAL.has(job.status) || job.status === "awaiting_review") return job;
if (Date.now() > deadline) throw new Error(`Job ${jobId} still ${job.status} after ${timeoutMs} ms`);
await new Promise((resolve) => setTimeout(resolve, intervalMs + Math.random() * 2_000));
}
}
}