diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12d76528..f65032d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- JSON viewer: sort keys alphabetically in rendered and copied output for object payloads. [PR #525](https://github.com/riverqueue/riverui/pull/525).
- Job state sidebar: only highlight `Running` when the selected jobs state is actually running, even with retained search filters in the URL. [Fixes #526](https://github.com/riverqueue/riverui/issues/526). [PR #527](https://github.com/riverqueue/riverui/pull/527).
- Job delete actions: require confirmation before deleting a single job or selected jobs in bulk. [Fixes #545](https://github.com/riverqueue/riverui/issues/545). [PR #546](https://github.com/riverqueue/riverui/pull/546).
+- Workflow detail: show the backend's not-found message instead of crashing when a workflow ID does not exist. [PR #564](https://github.com/riverqueue/riverui/pull/564).
## [v0.15.0] - 2026-02-26
diff --git a/src/utils/api.test.ts b/src/utils/api.test.ts
new file mode 100644
index 00000000..f5405a1c
--- /dev/null
+++ b/src/utils/api.test.ts
@@ -0,0 +1,67 @@
+import { getWorkflow, getWorkflowKey } from "@services/workflows";
+import { NotFoundError } from "@utils/api";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+describe("API 404 handling", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ });
+
+ it("surfaces workflow not found messages from the current API envelope", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ message: "Workflow not found: missing-workflow.",
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ status: 404,
+ },
+ ),
+ );
+
+ await expect(
+ getWorkflow({
+ client: undefined as never,
+ direction: "forward",
+ meta: undefined,
+ pageParam: undefined,
+ queryKey: getWorkflowKey("missing-workflow"),
+ signal: new AbortController().signal,
+ }),
+ ).rejects.toEqual(
+ new NotFoundError("Workflow not found: missing-workflow."),
+ );
+ });
+
+ it("rejects successful API responses that return the app shell", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("
River", {
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ status: 200,
+ }),
+ );
+
+ await expect(
+ getWorkflow({
+ client: undefined as never,
+ direction: "forward",
+ meta: undefined,
+ pageParam: undefined,
+ queryKey: getWorkflowKey("wf-with-missing-api-route"),
+ signal: new AbortController().signal,
+ }),
+ ).rejects.toMatchObject({
+ message:
+ "Expected JSON response from http://example.test/api/pro/workflows/wf-with-missing-api-route, received text/html; charset=utf-8.",
+ status: 200,
+ });
+ });
+});
diff --git a/src/utils/api.ts b/src/utils/api.ts
index fc1eca88..240c91fa 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -28,13 +28,24 @@ export const API = {
};
type APIError = {
- msg: string;
+ msg?: string;
};
type APIErrorResponse = {
- error: APIError;
+ error?: APIError;
+ message?: string;
};
+export class APIResponseError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ readonly statusText: string,
+ ) {
+ super(message);
+ }
+}
+
export class NotFoundError extends Error {}
export function APIUrl(path: string, query?: URLSearchParams) {
@@ -48,17 +59,55 @@ export function APIUrl(path: string, query?: URLSearchParams) {
return `${riverApiBaseUrl}${path}${query ? `?${query}` : ""}`;
}
+async function parseJSONResponse(
+ response: Response,
+ url: string,
+): Promise {
+ const contentType = response.headers.get("Content-Type");
+ if (!contentType?.includes("application/json")) {
+ const received = contentType || "unknown content type";
+ throw new APIResponseError(
+ `Expected JSON response from ${url}, received ${received}.`,
+ response.status,
+ response.statusText,
+ );
+ }
+
+ return (await response.json()) as TResponse;
+}
+
async function request(
url: string,
config: RequestInit,
): Promise {
- const response = await fetch(url, config);
+ const headers = new Headers(config.headers);
+ if (!headers.has("Accept")) {
+ headers.set("Accept", "application/json");
+ }
+
+ const response = await fetch(url, { ...config, headers });
if (response.ok) {
- return await response.json();
- } else if (response.status == 404) {
+ return await parseJSONResponse(response, url);
+ }
+
+ const message = await responseErrorMessage(response);
+ if (response.status == 404) {
+ throw new NotFoundError(message || "Resource not found.");
+ }
+
+ throw new APIResponseError(
+ message || `Request failed with status ${response.status}.`,
+ response.status,
+ response.statusText,
+ );
+}
+
+async function responseErrorMessage(response: Response): Promise {
+ const contentType = response.headers.get("Content-Type");
+ if (contentType?.includes("application/json")) {
const json = (await response.json()) as APIErrorResponse;
- throw new NotFoundError(json.error.msg);
- } else {
- throw new Error(`unhandled response with status ${response.status}`);
+ return json.message ?? json.error?.msg ?? "";
}
+
+ return (await response.text()).trim();
}