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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe("runHttpRequest", () => {
mockHeaders.append("Content-Type", "application/json");

const mockResponse = {
ok: true,
status: 200,
headers: mockHeaders,
body: {
Expand Down Expand Up @@ -90,6 +91,7 @@ describe("runHttpRequest", () => {
mockHeaders.append("Content-Type", "application/json");

const mockResponse = {
ok: true,
status: 200,
headers: mockHeaders,
body: {
Expand Down Expand Up @@ -142,6 +144,7 @@ describe("runHttpRequest", () => {
mockHeaders.append("Content-Type", "application/json");

const mockResponse = {
ok: true,
status: 200,
headers: mockHeaders,
body: {
Expand Down Expand Up @@ -192,4 +195,53 @@ describe("runHttpRequest", () => {
// Clean up
subscription.unsubscribe();
});

it("should throw HTTP error on occurs", async () => {
// Mock a 404 error response with JSON body
const mockHeaders = new Headers();
mockHeaders.append("content-type", "application/json");

const mockText = '{"message":"User not found"}';

const mockResponse = {
ok: false,
status: 404,
headers: mockHeaders,
// our error-path reads .text() (not streaming)
text: jest.fn().mockResolvedValue(mockText),
} as unknown as Response;

// Override fetch for this test
fetchMock.mockResolvedValue(mockResponse);

const observable = runHttpRequest("https://example.com/api", { method: "GET" });

const nextSpy = jest.fn();

await new Promise<void>((resolve) => {
const sub = observable.subscribe({
next: nextSpy,
error: (err: any) => {
// error should carry status + parsed payload
expect(err).toBeInstanceOf(Error);
expect(err.status).toBe(404);
expect(err.payload).toEqual({ message: "User not found" });
// readable message is okay too (optional)
expect(err.message).toContain("HTTP 404");
expect(err.message).toContain("User not found");
resolve();
sub.unsubscribe();
},
complete: () => {
fail("Should not complete on HTTP error");
},
});
});

// Should not have emitted any data events on error short-circuit
expect(nextSpy).not.toHaveBeenCalled();

// Ensure we read the error body exactly once
expect((mockResponse as any).text).toHaveBeenCalledTimes(1);
});
});
20 changes: 19 additions & 1 deletion typescript-sdk/packages/client/src/run/http-request.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Observable, from, defer, throwError } from "rxjs";
import { switchMap } from "rxjs/operators";
import { mergeMap, switchMap } from "rxjs/operators";

export enum HttpEventType {
HEADERS = "headers",
Expand All @@ -23,6 +23,24 @@ export const runHttpRequest = (url: string, requestInit: RequestInit): Observabl
// Defer the fetch so that it's executed when subscribed to
return defer(() => from(fetch(url, requestInit))).pipe(
switchMap((response) => {
if (!response.ok) {
const contentType = response.headers.get("content-type") || "";
// Read the (small) error body once, then error the stream
return from(response.text()).pipe(
mergeMap((text) => {
let payload: unknown = text;
if (contentType.includes("application/json")) {
try { payload = JSON.parse(text); } catch {/* keep raw text */}
}
const err: any = new Error(
`HTTP ${response.status}: ${typeof payload === "string" ? payload : JSON.stringify(payload)}`
);
err.status = response.status;
err.payload = payload;
return throwError(() => err);
})
);
}
// Emit headers event first
const headersEvent: HttpHeadersEvent = {
type: HttpEventType.HEADERS,
Expand Down
Loading