Skip to content

Commit 31edfb5

Browse files
committed
fix: surface server error body on non-2xx responses
1 parent 87588f7 commit 31edfb5

2 files changed

Lines changed: 95 additions & 2 deletions

File tree

src/lib/apiClient.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,18 @@ class ApiClient {
152152
const res = await fn(this.axiosAgent);
153153
return new ApiResponse<T>(res);
154154
} catch (error: any) {
155-
if (error.response && !raise_error) {
156-
return new ApiResponse<T>(error.response);
155+
if (error.response) {
156+
if (!raise_error) {
157+
return new ApiResponse<T>(error.response);
158+
}
159+
const body = error.response.data;
160+
const serverMessage =
161+
typeof body === "string"
162+
? body
163+
: (body?.message ?? body?.error ?? JSON.stringify(body));
164+
if (serverMessage) {
165+
error.message = `Request failed with status code ${error.response.status}: ${serverMessage}`;
166+
}
157167
}
158168
throw error;
159169
}

tests/lib/apiClient.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
const { postMock } = vi.hoisted(() => ({ postMock: vi.fn() }));
4+
5+
vi.mock("axios", () => ({
6+
default: {
7+
create: () => ({
8+
get: vi.fn(),
9+
post: postMock,
10+
put: vi.fn(),
11+
patch: vi.fn(),
12+
delete: vi.fn(),
13+
}),
14+
},
15+
}));
16+
17+
vi.mock("../../src/config", () => ({
18+
default: {
19+
browserstackLocalOptions: {},
20+
},
21+
}));
22+
23+
// utils.ts (a transitive import of apiClient) imports trackMCP from index.ts,
24+
// whose top-level main() would otherwise run on import. Stub it out.
25+
vi.mock("../../src/index", () => ({
26+
trackMCP: vi.fn(),
27+
}));
28+
29+
import { apiClient } from "../../src/lib/apiClient";
30+
31+
describe("apiClient error surfacing", () => {
32+
beforeEach(() => {
33+
postMock.mockReset();
34+
});
35+
36+
it("includes the server's JSON error message in the thrown error", async () => {
37+
postMock.mockRejectedValueOnce({
38+
message: "Request failed with status code 400",
39+
response: {
40+
status: 400,
41+
data: {
42+
success: false,
43+
message: "Max Testcase IDs supported limit exceeded.",
44+
},
45+
},
46+
});
47+
48+
await expect(
49+
apiClient.post({ url: "https://example.com/api", body: {} }),
50+
).rejects.toMatchObject({
51+
message:
52+
"Request failed with status code 400: Max Testcase IDs supported limit exceeded.",
53+
});
54+
});
55+
56+
it("falls back to a string error body", async () => {
57+
postMock.mockRejectedValueOnce({
58+
message: "Request failed with status code 422",
59+
response: { status: 422, data: "Unprocessable Entity" },
60+
});
61+
62+
await expect(
63+
apiClient.post({ url: "https://example.com/api", body: {} }),
64+
).rejects.toMatchObject({
65+
message: "Request failed with status code 422: Unprocessable Entity",
66+
});
67+
});
68+
69+
it("returns the response instead of throwing when raise_error is false", async () => {
70+
postMock.mockRejectedValueOnce({
71+
message: "Request failed with status code 400",
72+
response: { status: 400, data: { success: false } },
73+
});
74+
75+
const res = await apiClient.post({
76+
url: "https://example.com/api",
77+
body: {},
78+
raise_error: false,
79+
});
80+
expect(res.status).toBe(400);
81+
expect(res.data).toEqual({ success: false });
82+
});
83+
});

0 commit comments

Comments
 (0)