{"record":{"id":"ce7eb9c6b5af7470","repo":"Stirling-Tools/Stirling-PDF","slug":"saas-request-failed-res-status","errorCode":null,"errorMessage":"SaaS request failed (${res.status})","messagePattern":"SaaS request failed \\((.+?)\\)","errorType":"http","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"frontend/editor/src/portal/api/http.ts","lineNumber":286,"sourceCode":"  path: string,\n  options: HttpRequestOptions = {},\n): Promise<string> {\n  const base = saasBaseUrl();\n  // null = unset (self-hosted, no VITE_SAAS_API_URL). \"\" is same-origin (SaaS) — valid.\n  if (base === null) throw new SaasUnconfiguredError();\n  const token = await getPortalSaasToken();\n  if (!token) throw new SaasNotLinkedError();\n  const res = await fetch(`${base}${path}`, {\n    method: options.method ?? \"GET\",\n    headers: {\n      Accept: \"text/plain\",\n      Authorization: `Bearer ${token}`,\n      ...options.headers,\n    },\n    signal: options.signal,\n  });\n  if (!res.ok) {\n    throw new Error(`SaaS request failed (${res.status})`);\n  }\n  return res.text();\n}\n\n/** SaaS GET returning a binary Blob, with the Supabase JWT attached. */\nasync function saasBlob(\n  path: string,\n  options: HttpRequestOptions = {},\n): Promise<Blob> {\n  const base = saasBaseUrl();\n  // Same-origin SaaS resolves to \"\" (falsy); only null means unconfigured.\n  if (base === null) throw new SaasUnconfiguredError();\n  const token = await getPortalSaasToken();\n  if (!token) throw new SaasNotLinkedError();\n  const res = await fetch(`${base}${path}`, {\n    method: options.method ?? \"GET\",\n    headers: { Authorization: `Bearer ${token}`, ...options.headers },\n    signal: options.signal,","sourceCodeStart":268,"sourceCodeEnd":304,"githubUrl":"https://github.com/Stirling-Tools/Stirling-PDF/blob/9ef20dcab80b85041912f045e17a6aea1d08f969/frontend/editor/src/portal/api/http.ts#L268-L304","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Catch broadly (Error) for saasText results since it is not an HttpError — or check the message prefix 'SaaS request failed'.","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.","For 404, verify the resource id/path; for 401/403 refresh the portalSaasSession token."],"exampleFix":"// before — plain Error, loses statusText + body, breaks instanceof HttpError\nif (!res.ok) {\n  throw new Error(`SaaS request failed (${res.status})`);\n}\n\n// after — uniform HttpError so callers handle text + json paths identically\nif (!res.ok) {\n  const detail = await res.text().catch(() => null);\n  throw new HttpError(res.status, res.statusText, detail);\n}","handlingStrategy":"try-catch","validationCode":"null","typeGuard":"// NOT an HttpError — must match by message. (Fix the source to throw HttpError instead.)\nfunction isSaasTextFailure(e: unknown): boolean {\n  return e instanceof Error && /^SaaS request failed \\(\\d+\\)$/.test(e.message);\n}\nfunction saasTextStatus(e: unknown): number | null {\n  if (!isSaasTextFailure(e)) return null;\n  const m = (e as Error).message.match(/\\((\\d+)\\)/);\n  return m ? Number(m[1]) : null;\n}","tryCatchPattern":"try { return await apiClient.saas.text(path); }\ncatch (e) {\n  if (isSaasTextFailure(e)) {\n    const status = saasTextStatus(e);\n    if (status === 404) return null;\n  }\n  throw e;\n}","preventionTips":["saasText throws a plain Error, not HttpError — do not rely on instanceof HttpError here.","Fix the inconsistency: change the source to throw new HttpError(res.status, res.statusText, body).","Parse the status from the message until the source is unified."],"tags":["portal","http","saas","response-handling","inconsistency"],"backgroundTag":null,"analyzedSha":"9ef20dcab80b85041912f045e17a6aea1d08f969","analyzedAt":"2026-08-13T22:11:39.827Z","schemaVersion":2},"datasetVersion":"2026-08-14T05:17:29.042Z"}