Errors and retries
fetchSite, fetchArticle, fetchArticleBySlug, resolvePublicationUri, listSites, listArticles, and fetchProfile throw one of three typed errors, all exported from @scribe-atp/core:
| Error | Thrown when | Should you retry? |
|---|---|---|
NotFoundError |
The fetch succeeded, but the site/article genuinely doesn’t exist (bad slug, deleted record) | No — it will fail the same way every time |
PdsFetchError |
The PDS responded, but with a non-ok HTTP status. The service is up — this specific operation failed | Yes — this is usually transient |
PdsUnreachableError |
The request never got a response at all — DNS failure, connection refused, timeout | Yes — but this suggests a broader outage, not just one bad request |
PdsUnreachableError extends PdsFetchError, so an instanceof PdsFetchError check matches both — check instanceof PdsUnreachableError first if your UI wants to say something more specific than “couldn’t load that” (e.g. “the service is down” vs. “something went wrong loading this”).
import { fetchSite, NotFoundError, PdsFetchError, PdsUnreachableError } from '@scribe-atp/core';
try { const site = await fetchSite(author, publicationUrl, signal);} catch (err) { if (err instanceof NotFoundError) { // show a 404 — retrying won't help } else if (err instanceof PdsUnreachableError) { // couldn't reach the PDS at all — safe to retry, but worth a // distinct "service is down" message if your UI differentiates } else if (err instanceof PdsFetchError) { // the PDS responded with an error — safe to retry } throw err;}Retrying with withRetry
Section titled “Retrying with withRetry”withRetry wraps any of the fetch functions above with configurable retry-with-backoff. It’s a generic helper — it doesn’t know which function you’re calling, so it works with fetchSite, fetchArticle, fetchArticleBySlug, or resolvePublicationUri equally.
function withRetry<T>( fn: () => Promise<T>, options?: { attempts?: number; // total attempts including the first — default 5 delaysMs?: number[]; // delay before each retry — default [300, 600, 1200, 2400] signal?: AbortSignal; }): Promise<T>import { fetchSite, withRetry } from '@scribe-atp/core';
const site = await withRetry(() => fetchSite(author, publicationUrl, signal), { signal });It never retries NotFoundError — retrying a genuine 404 just delays showing it. It also stops immediately if the passed signal is aborted, instead of continuing to retry a request nobody is waiting for anymore. Everything else is retried, including plain Errors thrown by code that predates the typed errors above.
withRetry is opt-in — none of @scribe-atp/core’s fetch functions retry automatically. This matters for callers like build-time static-site generation (@scribe-atp/next’s generateStaticParams), where failing fast is usually preferable to eating several seconds of backoff during a build.
Pairing retries with a loading state
Section titled “Pairing retries with a loading state”withRetry only handles the retry loop — it doesn’t know anything about your UI. In a server-rendered framework, the common pattern is to attempt the fetch once synchronously (so metadata/SEO tags stay available on the fast, successful path), and only fall back to a retrying, streamed fetch — paired with a loading spinner — if that first attempt fails:
// React Router v7/v8 framework modeexport async function loader({ request }: Route.LoaderArgs) { try { const site = await fetchSite(author, publicationUrl, request.signal); return { status: 'ok' as const, site }; } catch (err) { if (err instanceof NotFoundError) throw new Response('Not found', { status: 404 }); // Stream the retries — don't await — so the page shell renders // immediately and a <Suspense> fallback covers the wait. return { status: 'retrying' as const, site: withRetry(() => fetchSite(author, publicationUrl, request.signal), { attempts: 4, signal: request.signal, }), }; }}See your framework’s guide for how to render a Suspense/Await fallback around the streamed promise, and an error boundary for when all retries are exhausted.