Skip to content

Error handling

There is exactly one error type across every SDK: GhostwritrError. It is always catchable, always inspectable, and the same class everywhere — so a single instanceof check works whether you imported from @ghostwritr/feed, @ghostwritr/next, @ghostwritr/astro, or @ghostwritr/react-router.

class GhostwritrError extends Error {
readonly status: number; // HTTP status, or 0 for client-side
readonly code: GhostwritrErrorCode | null;
readonly details?: unknown; // the offending URL or underlying cause
}
  • status is the HTTP status when the failure came from a response (e.g. 429, 500). It is 0 for anything that never reached a server: config, network, or a parse failure.
  • code is a stable, switchable identifier (below). Prefer it over status.
  • details carries context for debugging — often the URL that failed or the underlying network cause. Don’t surface it to users.

instanceof GhostwritrError is preserved across transpile targets, so it works regardless of your build.

type GhostwritrErrorCode =
| "CONFIG"
| "NETWORK_ERROR"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "RATE_LIMITED"
| "SERVER_ERROR"
| "INVALID_RESPONSE"
| (string & {});
CodestatusMeans
CONFIG0A bad call — missing siteId, missing slug, or an invalid staticBaseUrl. A bug to fix, not retry.
NETWORK_ERROR0The fetch itself threw (DNS, offline, TLS). The request never got a response.
NOT_FOUND404The feed (or a requested object) isn’t there. For a site whose feed hasn’t been built yet, this is what you get.
RATE_LIMITED429Throttled — back off and retry.
UNAUTHORIZED401Mapped from status; not expected on the keyless read path.
FORBIDDEN403Mapped from status; not expected on the keyless read path.
SERVER_ERROR≥500The feed origin returned a server error.
INVALID_RESPONSEvariesThe response wasn’t valid JSON, or violated the article/feed contract (a guaranteed field missing or mistyped).

The keyless read path has no authed fallback — so when something is wrong, the SDK throws rather than silently returning empty or stale data. Two cases are worth telling apart:

  • NOT_FOUND — the feed isn’t there yet. A site that hasn’t been backfilled has no manifest, so the read fails closed with NOT_FOUND. This is the expected, recoverable state for a brand-new site. Treat it as “nothing published yet”: render an empty state, don’t crash the build. (Note fetchArticle({ slug }) resolves a missing single article to null rather than throwing — NOT_FOUND is for an absent feed/manifest, not an absent slug.)
  • INVALID_RESPONSE — the contract was violated. The feed responded, but the payload was non-JSON, the manifest was malformed, or a published article was missing a guaranteed field. A complete article is the contract, so this is never coerced — it surfaces loudly. This points at a real problem, not an empty site.
import { GhostwritrError } from "@ghostwritr/feed";
try {
const articles = await fetchArticles({ siteId });
return render(articles);
} catch (err) {
if (err instanceof GhostwritrError) {
if (err.code === "NOT_FOUND") return renderEmptyState(); // nothing published yet
if (err.code === "RATE_LIMITED") return retryLater();
// INVALID_RESPONSE, SERVER_ERROR, NETWORK_ERROR, CONFIG, …
}
throw err;
}

That distinction is the whole philosophy: a missing feed is a normal, handled state (NOT_FOUND); a broken feed is a hard failure you want to see (INVALID_RESPONSE). Nothing in between is papered over.