Stirling-Tools/Stirling-PDF · error · Error

SaaS request failed (${res.status})

Error message

SaaS request failed (${res.status})

What it means

Generic Error thrown by saasText() when the fetch returned a non-ok status. Unlike the JSON paths (which use HttpError with status+statusText+body), this throws a plain Error with only `SaaS request failed (${res.status})` — no statusText, no body, and it is NOT an HttpError instance. This is an inconsistency: callers using `instanceof HttpError` will miss it.

Source

Thrown at frontend/editor/src/portal/api/http.ts:286

  path: string,
  options: HttpRequestOptions = {},
): Promise<string> {
  const base = saasBaseUrl();
  // null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid.
  if (base === null) throw new SaasUnconfiguredError();
  const token = await getPortalSaasToken();
  if (!token) throw new SaasNotLinkedError();
  const res = await fetch(`${base}${path}`, {
    method: options.method ?? "GET",
    headers: {
      Accept: "text/plain",
      Authorization: `Bearer ${token}`,
      ...options.headers,
    },
    signal: options.signal,
  });
  if (!res.ok) {
    throw new Error(`SaaS request failed (${res.status})`);
  }
  return res.text();
}

/** SaaS GET returning a binary Blob, with the Supabase JWT attached. */
async function saasBlob(
  path: string,
  options: HttpRequestOptions = {},
): Promise<Blob> {
  const base = saasBaseUrl();
  // Same-origin SaaS resolves to "" (falsy); only null means unconfigured.
  if (base === null) throw new SaasUnconfiguredError();
  const token = await getPortalSaasToken();
  if (!token) throw new SaasNotLinkedError();
  const res = await fetch(`${base}${path}`, {
    method: options.method ?? "GET",
    headers: { Authorization: `Bearer ${token}`, ...options.headers },
    signal: options.signal,

View on GitHub (pinned to 9ef20dcab8)

Solutions

  1. Catch broadly (Error) for saasText results since it is not an HttpError — or check the message prefix 'SaaS request failed'.
  2. Fix the inconsistency at the source: throw new HttpError(res.status, res.statusText, await res.text().catch(() => null)) so callers get a uniform error type with the body.
  3. For 404, verify the resource id/path; for 401/403 refresh the portalSaasSession token.

Example fix

// before — plain Error, loses statusText + body, breaks instanceof HttpError
if (!res.ok) {
  throw new Error(`SaaS request failed (${res.status})`);
}

// after — uniform HttpError so callers handle text + json paths identically
if (!res.ok) {
  const detail = await res.text().catch(() => null);
  throw new HttpError(res.status, res.statusText, detail);
}
Defensive patterns

Strategy: try-catch

Validate before calling

null

Type guard

// NOT an HttpError — must match by message. (Fix the source to throw HttpError instead.)
function isSaasTextFailure(e: unknown): boolean {
  return e instanceof Error && /^SaaS request failed \(\d+\)$/.test(e.message);
}
function saasTextStatus(e: unknown): number | null {
  if (!isSaasTextFailure(e)) return null;
  const m = (e as Error).message.match(/\((\d+)\)/);
  return m ? Number(m[1]) : null;
}

Try / catch

try { return await apiClient.saas.text(path); }
catch (e) {
  if (isSaasTextFailure(e)) {
    const status = saasTextStatus(e);
    if (status === 404) return null;
  }
  throw e;
}

Prevention

When it happens

Trigger: saasText fetch completes but res.ok is false — a 4xx/5xx from the SaaS backend on a text endpoint (e.g. licence file not found, server error).

Common situations: The licence/resource requested does not exist (404); the SaaS backend errored generating the text (500); a stale JWT produced 401/403 on the text endpoint.

Related errors


AI-assisted analysis of Stirling-Tools/Stirling-PDF@9ef20dcab8 (2026-08-13). Data as JSON: /api/errors/ce7eb9c6b5af7470. Report an issue: GitHub.