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.
Debounced search
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.