Skip to content

Commit 457643b

Browse files
committed
fix: surface HTTP errors when using http requests
1 parent 96830c5 commit 457643b

File tree

2 files changed

+68
-1
lines changed

2 files changed

+68
-1
lines changed

typescript-sdk/packages/client/src/run/__tests__/http-request.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,53 @@ describe("runHttpRequest", () => {
192192
// Clean up
193193
subscription.unsubscribe();
194194
});
195+
196+
it("should throw HTTP error on occurs", async () => {
197+
// Mock a 404 error response with JSON body
198+
const mockHeaders = new Headers();
199+
mockHeaders.append("content-type", "application/json");
200+
201+
const mockText = '{"message":"User not found"}';
202+
203+
const mockResponse = {
204+
ok: false,
205+
status: 404,
206+
headers: mockHeaders,
207+
// our error-path reads .text() (not streaming)
208+
text: jest.fn().mockResolvedValue(mockText),
209+
} as unknown as Response;
210+
211+
// Override fetch for this test
212+
fetchMock.mockResolvedValue(mockResponse);
213+
214+
const observable = runHttpRequest("https://example.com/api", { method: "GET" });
215+
216+
const nextSpy = jest.fn();
217+
218+
await new Promise<void>((resolve) => {
219+
const sub = observable.subscribe({
220+
next: nextSpy,
221+
error: (err: any) => {
222+
// error should carry status + parsed payload
223+
expect(err).toBeInstanceOf(Error);
224+
expect(err.status).toBe(404);
225+
expect(err.payload).toEqual({ message: "User not found" });
226+
// readable message is okay too (optional)
227+
expect(err.message).toContain("HTTP 404");
228+
expect(err.message).toContain("User not found");
229+
resolve();
230+
sub.unsubscribe();
231+
},
232+
complete: () => {
233+
fail("Should not complete on HTTP error");
234+
},
235+
});
236+
});
237+
238+
// Should not have emitted any data events on error short-circuit
239+
expect(nextSpy).not.toHaveBeenCalled();
240+
241+
// Ensure we read the error body exactly once
242+
expect((mockResponse as any).text).toHaveBeenCalledTimes(1);
243+
});
195244
});

typescript-sdk/packages/client/src/run/http-request.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Observable, from, defer, throwError } from "rxjs";
2-
import { switchMap } from "rxjs/operators";
2+
import { mergeMap, switchMap } from "rxjs/operators";
33

44
export enum HttpEventType {
55
HEADERS = "headers",
@@ -23,6 +23,24 @@ export const runHttpRequest = (url: string, requestInit: RequestInit): Observabl
2323
// Defer the fetch so that it's executed when subscribed to
2424
return defer(() => from(fetch(url, requestInit))).pipe(
2525
switchMap((response) => {
26+
if (!response.ok) {
27+
const contentType = response.headers.get("content-type") || "";
28+
// Read the (small) error body once, then error the stream
29+
return from(response.text()).pipe(
30+
mergeMap((text) => {
31+
let payload: unknown = text;
32+
if (contentType.includes("application/json")) {
33+
try { payload = JSON.parse(text); } catch {/* keep raw text */}
34+
}
35+
const err: any = new Error(
36+
`HTTP ${response.status}: ${typeof payload === "string" ? payload : JSON.stringify(payload)}`
37+
);
38+
err.status = response.status;
39+
err.payload = payload;
40+
return throwError(() => err);
41+
})
42+
);
43+
}
2644
// Emit headers event first
2745
const headersEvent: HttpHeadersEvent = {
2846
type: HttpEventType.HEADERS,

0 commit comments

Comments
 (0)