SDK guides

Errors and retries

fetchSite, fetchArticle, fetchArticleBySlug, resolvePublicationUri, listSites, listArticles, and fetchProfile throw one of three typed errors, all exported from @scribe-atp/core:

ErrorThrown whenShould you retry?
NotFoundErrorThe fetch succeeded, but the site/article genuinely doesn't exist (bad slug, deleted record)No — it will fail the same way every time
PdsFetchErrorThe PDS responded, but with a non-ok HTTP status. The service is up — this specific operation failedYes — this is usually transient
PdsUnreachableErrorThe request never got a response at all — DNS failure, connection refused, timeoutYes — 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;
}

Note

Most consumers don't need the three-way split — a single "temporarily unavailable, try again" message covering both PdsFetchError and PdsUnreachableError is enough. Reach for PdsUnreachableError specifically when your UI benefits from telling "this record had trouble loading" apart from "the whole service is down" — e.g. a multi-tenant reader/browser app where a visitor needs to know whether it's worth trying a different page right now.

Note

A cancelled request still rejects with a plain AbortError, not one of the typed errors above — see Request cancellation.

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

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.

Settings

Appearance