Skip to content

Patterns and recipes

Drop-in patterns for robust error handling, exhaustive pagination, debounced search, request batching, and periodic export.

Updated 2026-05-20

Copy-paste-ready patterns for the situations the docs don’t quite cover. All examples use @faqapp/core.

Robust error handler

A single helper that maps every SDK error class to a useful action:

import {
  FAQAPIError,
  FAQValidationError,
  FAQNotFoundError,
  FAQRateLimitError,
  FAQNetworkError,
  FAQTimeoutError
} from "@faqapp/core";

export async function safeCall<T>(fn: () => Promise<T>): Promise<T | null> {
  try {
    return await fn();
  } catch (err) {
    if (err instanceof FAQValidationError) {
      console.warn("validation failed", err.errors);
      return null;
    }
    if (err instanceof FAQNotFoundError) {
      return null;
    }
    if (err instanceof FAQRateLimitError) {
      await new Promise((r) => setTimeout(r, err.retryAfter * 1000));
      return safeCall(fn); // single retry; caller can wrap with bounded recursion
    }
    if (err instanceof FAQTimeoutError || err instanceof FAQNetworkError) {
      console.warn("transient", err.message);
      return null;
    }
    if (err instanceof FAQAPIError) {
      console.error(`[${err.code}] ${err.message} (trace: ${err.traceId})`);
      return null;
    }
    throw err; // genuinely unknown, re-throw
  }
}

// Usage:
const question = await safeCall(() => faq.questions.get("q_8X3F"));
if (!question) {
  // not found, validation failed, transient: all handled
}

Don’t make this recursive without a bound for production. Wrap with a max-attempts counter.

Exhaustive pagination

The cursor pattern, as a generic async iterable:

async function* paginate<T>(
  pageFn: (cursor: string | null) => Promise<{ data: T[]; meta: { pagination: { cursor: string | null } } }>
): AsyncIterable<T> {
  let cursor: string | null = null;
  do {
    const page = await pageFn(cursor);
    for (const item of page.data) yield item;
    cursor = page.meta.pagination.cursor;
  } while (cursor);
}

// Usage: iterate every published question across all pages
for await (const q of paginate((cursor) =>
  faq.questions.list({ status: "published", limit: 100, cursor })
)) {
  console.log(q.title);
}

Memory-friendly: items stream out as they’re fetched. Works for any list endpoint that returns the standard meta.pagination.cursor shape.

For a search box that queries as the user types:

import { useState, useEffect } from "react";

function useDebouncedFaqSearch(query: string, delay = 250) {
  const [results, setResults] = useState<Question[]>([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    const handle = setTimeout(async () => {
      const res = await faq.questions.list({ q: query, limit: 8 });
      setResults(res.data);
    }, delay);
    return () => clearTimeout(handle);
  }, [query, delay]);

  return results;
}

q is full-text search across title + answer. For the React hook version, @faqapp/react exposes useQuestionSearch(query, { debounce: 250 }) doing the same thing.

Request batching for high-write workloads

When you have hundreds of writes to make in a tight window, batch and throttle:

async function batchWrite<T, R>(
  items: T[],
  fn: (item: T) => Promise<R>,
  concurrency = 5
): Promise<R[]> {
  const out: R[] = [];
  for (let i = 0; i < items.length; i += concurrency) {
    const chunk = items.slice(i, i + concurrency);
    out.push(...(await Promise.all(chunk.map(fn))));
  }
  return out;
}

await batchWrite(rows, (row) =>
  faq.questions.create({
    title: row.q,
    answer: row.a,
    categorySlug: row.cat
  })
);

Concurrency of 5 sits comfortably under the Starter burst limit (300/min). Tune up for Pro, down if you see 429s.

Periodic export

Mirror your FAQ to a JSON file on disk every hour, atomically:

import { writeFile, rename } from "node:fs/promises";

async function exportAll() {
  const all: Question[] = [];
  for await (const q of paginate((cursor) =>
    faq.questions.list({ limit: 100, cursor })
  )) {
    all.push(q);
  }

  const tmp = `/var/lib/faq-cache/questions.json.tmp`;
  const final = `/var/lib/faq-cache/questions.json`;
  await writeFile(tmp, JSON.stringify(all));
  await rename(tmp, final); // atomic
}

setInterval(exportAll, 60 * 60 * 1000);
exportAll(); // first run immediately

Atomic via temp-file-plus-rename so readers never see a half-written file. Pair with a CDN cache that revalidates against this exported file for high-traffic public surfaces; read traffic stops hitting the API entirely.

Webhooks (when you don’t have them)

Webhooks aren’t in the v1 API yet. The fastest workaround:

// Poll the questions list every 5 minutes; emit "updated" events for
// rows whose `updatedAt` is newer than your last seen timestamp.

let lastSeen = new Date(0);

async function tick() {
  for await (const q of paginate((c) => faq.questions.list({ limit: 100, cursor: c }))) {
    const updated = new Date(q.updatedAt);
    if (updated > lastSeen) emit("question.updated", q);
  }
  lastSeen = new Date();
}

setInterval(tick, 5 * 60 * 1000);

Less elegant than webhooks but works today. Real webhooks ship when there’s enough demand to justify the queue infrastructure. Email if you need them.