Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 67 additions & 0 deletions src/utils/api.test.ts
Original file line number Diff line number Diff line change
@@ -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 =
'<script id="config__json">{"apiUrl":"http://example.test/api"}</script>';

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 =
'<script id="config__json">{"apiUrl":"http://example.test/api"}</script>';

vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("<!doctype html><title>River</title>", {
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,
});
});
});
65 changes: 57 additions & 8 deletions src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -48,17 +59,55 @@ export function APIUrl(path: string, query?: URLSearchParams) {
return `${riverApiBaseUrl}${path}${query ? `?${query}` : ""}`;
}

async function parseJSONResponse<TResponse>(
response: Response,
url: string,
): Promise<TResponse> {
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<TResponse>(
url: string,
config: RequestInit,
): Promise<TResponse> {
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<TResponse>(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<string> {
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();
}
Loading