Concepts

How pagination, errors, hooks, retries, abort signals, and custom fetch work - with runnable demonstrations of each.

On this page

Pagination

Every list() method returns a Page<T> - a frozen snapshot of the items on the current page plus navigation helpers. Pagination is offset-based: page size must be one of 6, 12, 24, 48, 100 (the server rejects everything else).

Walk forward with .next()
// Get page 1, then call .next() to fetch page 2.
let page = await client.prompts.list({ scope: "public", pageSize: 6 });
console.log(`page ${page.page}: ${page.items.length} items, hasNext=${page.hasNext}`);

if (page.hasNext) {
  page = await page.next();
  console.log(`page ${page.page}: ${page.items.length} items, hasPrev=${page.hasPrev}`);
}

return {
  currentPage: page.page,
  pageSize: page.pageSize,
  total: page.total,
  hasNext: page.hasNext,
  hasPrev: page.hasPrev,
};

Calling .next() on the last page (or .prev() on the first) rejects with a RangeError. Always check hasNext / hasPrev before navigating.

Async iteration with listAll()

For batch jobs, listAll() is an async iterator that walks every page and yields items one at a time. Pagination is handled for you.

Iterate up to 24 prompts
const titles = [];
let count = 0;

for await (const prompt of client.prompts.listAll({ scope: "public", pageSize: 12 })) {
  titles.push(prompt.title);
  if (++count >= 24) break;
}

console.log(`Collected ${titles.length} titles across pages.`);
return titles;

Error handling

Every non-2xx response throws a typed subclass of PromptyError. Use instanceof to narrow:

PromptyError                  // base class
├── PromptyConfigError         // bad config (sync, at construction)
├── PromptyNetworkError        // fetch rejected (DNS, TLS, offline)
├── PromptyTimeoutError        // request exceeded timeoutMs
└── PromptyHttpError           // base for non-2xx responses
    ├── PromptyValidationError // 400
    ├── PromptyAuthError       // 401
    ├── PromptyNotFoundError   // 404
    ├── PromptyRateLimitError  // 429 (carries optional retryAfter)
    └── PromptyServerError     // 5xx
Catch a 404
try {
  await client.prompts.get("definitely-not-an-id");
} catch (err) {
  if (err instanceof PromptyNotFoundError) {
    console.log("got 404:", err.message, "status=", err.status);
    return { caught: "PromptyNotFoundError", message: err.message };
  }
  if (err instanceof PromptyAuthError) {
    return { caught: "PromptyAuthError", message: err.message };
  }
  if (err instanceof PromptyError) {
    return { caught: err.name, message: err.message };
  }
  throw err;
}

All errors carry status, optionally requestId (from the x-request-id response header), and the raw response. PromptyRateLimitError additionally exposes retryAfter when the server provided a Retry-After header.

Request & response hooks

onRequest fires before every fetch; onResponse fires after every fetch (success or error) with timing data. Both can be sync or async - async hooks are awaited before the request continues. Useful for tracing, logging, metrics.

Trace one request
// Build a fresh client with hooks attached, reusing the same API key.
const traced = [];
const observed = createPromptyClient({
  apiKey: localStorage.getItem("prompty-tools-core/api-key") ?? "",
  onRequest: (ctx) =>
    traced.push({ phase: "request", method: ctx.method, url: ctx.url }),
  onResponse: (ctx) =>
    traced.push({
      phase: "response",
      status: ctx.status,
      ok: ctx.ok,
      durationMs: ctx.durationMs,
    }),
});

await observed.prompts.list({ pageSize: 6 });
return traced;

Retries

Retries are off by default. When you opt in via maxRetries, only GET requests are retried, and only on 429 or 5xx responses (plus timeouts/network errors are not retried - they're surfaced immediately). Backoff is exponential with jitter, capped at 30 seconds.

Mutations are never retried automatically - there's no idempotency-key protocol on the server, so the client refuses to risk duplicate writes. Wrap mutations in your own retry logic if you need it.

import { createPromptyClient } from "@prompty-tools/core";

const client = createPromptyClient({
  apiKey: process.env.PROMPTY_API_KEY,
  maxRetries: 3, // GET-only, on 429/5xx
});

Aborting requests

Every list method accepts an AbortSignal via params.signal. The detail/mutation methods don't take an explicit signal - for those, configure a client-level timeoutMs instead, or pass a custom fetch that wraps an AbortController.

Abort a list call
const controller = new AbortController();

// Abort almost immediately - well before the network round-trip completes.
setTimeout(() => controller.abort(), 1);

try {
  await client.prompts.list({ scope: "public", signal: controller.signal });
  return { aborted: false };
} catch (err) {
  return { aborted: true, error: `${err.name}: ${err.message}` };
}

Bringing your own fetch

Every option is wired through a single fetch seam - pass your own implementation to add tracing, run on edge runtimes with custom request handling, or stub the network in tests:

import { createPromptyClient } from "@prompty-tools/core";

const client = createPromptyClient({
  apiKey: process.env.PROMPTY_API_KEY!,
  fetch: async (input, init) => {
    const start = performance.now();
    const res = await fetch(input, init);
    const ms = performance.now() - start;
    metrics.histogram("prompty.latency", ms, { url: String(input) });
    return res;
  },
});

The default is globalThis.fetch. The same seam is what powers the package's own test suite - there's no MSW or nock involved, just an injected fake fetch.

Wrap fetch with a header injector
let captured;
const wrapped = createPromptyClient({
  apiKey: localStorage.getItem("prompty-tools-core/api-key") ?? "",
  fetch: async (input, init) => {
    const merged = {
      ...init,
      headers: { ...init?.headers, "x-prompty-trace": "demo" },
    };
    captured = { url: String(input), headers: merged.headers };
    return fetch(input, merged);
  },
});

await wrapped.prompts.list({ pageSize: 6 });
return captured;