Skip to content

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;
}

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.

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 mode
export 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.