dify/sdks/nodejs-client/src/http/client.test.ts
agenthaulk dc83e8aa09
chore: resolve oxlint warnings across web and SDK (#34540)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-04-21 04:43:45 +00:00

528 lines
16 KiB
TypeScript

import { Readable, Stream } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
APIError,
AuthenticationError,
FileUploadError,
NetworkError,
RateLimitError,
TimeoutError,
ValidationError,
} from "../errors/dify-error";
import { HttpClient } from "./client";
const stubFetch = (): ReturnType<typeof vi.fn> => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};
const getFetchCall = (
fetchMock: ReturnType<typeof vi.fn>,
index = 0
): [string, RequestInit | undefined] => {
const call = fetchMock.mock.calls[index];
if (!call) {
throw new Error(`Missing fetch call at index ${index}`);
}
return call as [string, RequestInit | undefined];
};
const toHeaderRecord = (headers: HeadersInit | undefined): Record<string, string> =>
Object.fromEntries(new Headers(headers).entries());
const jsonResponse = (
body: unknown,
init: ResponseInit = {}
): Response =>
new Response(JSON.stringify(body), {
...init,
headers: {
"content-type": "application/json",
...init.headers,
},
});
const textResponse = (body: string, init: ResponseInit = {}): Response =>
new Response(body, {
...init,
headers: {
...init.headers,
},
});
describe("HttpClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("builds requests with auth headers and JSON content type", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
jsonResponse({ ok: true }, { status: 200, headers: { "x-request-id": "req" } })
);
const client = new HttpClient({ apiKey: "test" });
const response = await client.request({
method: "POST",
path: "/chat-messages",
data: { user: "u" },
});
expect(response.requestId).toBe("req");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe("https://api.dify.ai/v1/chat-messages");
expect(toHeaderRecord(init?.headers)).toMatchObject({
authorization: "Bearer test",
"content-type": "application/json",
"user-agent": "dify-client-node",
});
expect(init?.body).toBe(JSON.stringify({ user: "u" }));
});
it("serializes array query params", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
method: "GET",
path: "/datasets",
query: { tag_ids: ["a", "b"], limit: 2 },
});
const [url] = getFetchCall(fetchMock);
expect(new URL(url).searchParams.toString()).toBe(
"tag_ids=a&tag_ids=b&limit=2"
);
});
it("returns SSE stream helpers", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
new Response('data: {"text":"hi"}\n\n', {
status: 200,
headers: { "x-request-id": "req" },
})
);
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestStream({
method: "POST",
path: "/chat-messages",
data: { user: "u" },
});
expect(stream.status).toBe(200);
expect(stream.requestId).toBe("req");
await expect(stream.toText()).resolves.toBe("hi");
});
it("returns binary stream helpers", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
new Response("chunk", {
status: 200,
headers: { "x-request-id": "req" },
})
);
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestBinaryStream({
method: "POST",
path: "/text-to-audio",
data: { user: "u", text: "hi" },
});
expect(stream.status).toBe(200);
expect(stream.requestId).toBe("req");
expect(stream.data).toBeInstanceOf(Readable);
});
it("respects form-data headers", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
const form = new FormData();
form.append("file", new Blob(["abc"]), "file.txt");
await client.requestRaw({
method: "POST",
path: "/files/upload",
data: form,
});
const [, init] = getFetchCall(fetchMock);
expect(toHeaderRecord(init?.headers)).toMatchObject({
authorization: "Bearer test",
});
expect(toHeaderRecord(init?.headers)["content-type"]).toBeUndefined();
});
it("sends legacy form-data as a readable request body", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
const legacyForm = Object.assign(Readable.from(["chunk"]), {
append: vi.fn(),
getHeaders: () => ({
"content-type": "multipart/form-data; boundary=test",
}),
});
await client.requestRaw({
method: "POST",
path: "/files/upload",
data: legacyForm,
});
const [, init] = getFetchCall(fetchMock);
expect(toHeaderRecord(init?.headers)).toMatchObject({
authorization: "Bearer test",
"content-type": "multipart/form-data; boundary=test",
});
expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe(
"half"
);
expect(init?.body).not.toBe(legacyForm);
});
it("rejects legacy form-data objects that are not readable streams", async () => {
const fetchMock = stubFetch();
const client = new HttpClient({ apiKey: "test" });
const legacyForm = {
append: vi.fn(),
getHeaders: () => ({
"content-type": "multipart/form-data; boundary=test",
}),
};
await expect(
client.requestRaw({
method: "POST",
path: "/files/upload",
data: legacyForm,
})
).rejects.toBeInstanceOf(FileUploadError);
expect(fetchMock).not.toHaveBeenCalled();
});
it("accepts legacy pipeable streams that are not Readable instances", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
const legacyStream = new Stream() as Stream &
NodeJS.ReadableStream & {
append: ReturnType<typeof vi.fn>;
getHeaders: () => Record<string, string>;
};
legacyStream.readable = true;
legacyStream.pause = () => legacyStream;
legacyStream.resume = () => legacyStream;
legacyStream.append = vi.fn();
legacyStream.getHeaders = () => ({
"content-type": "multipart/form-data; boundary=test",
});
queueMicrotask(() => {
legacyStream.emit("data", Buffer.from("chunk"));
legacyStream.emit("end");
});
await client.requestRaw({
method: "POST",
path: "/files/upload",
data: legacyStream as unknown as FormData,
});
const [, init] = getFetchCall(fetchMock);
expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe(
"half"
);
});
it("returns buffers for byte responses", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
})
);
const client = new HttpClient({ apiKey: "test" });
const response = await client.request<Buffer, "bytes">({
method: "GET",
path: "/files/file-1/preview",
responseType: "bytes",
});
expect(Buffer.isBuffer(response.data)).toBe(true);
expect(Array.from(response.data.values())).toEqual([1, 2, 3]);
});
it("keeps arraybuffer as a backward-compatible binary alias", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
new Response(Uint8Array.from([4, 5, 6]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
})
);
const client = new HttpClient({ apiKey: "test" });
const response = await client.request<Buffer, "arraybuffer">({
method: "GET",
path: "/files/file-1/preview",
responseType: "arraybuffer",
});
expect(Buffer.isBuffer(response.data)).toBe(true);
expect(Array.from(response.data.values())).toEqual([4, 5, 6]);
});
it("returns null for empty no-content responses", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
const client = new HttpClient({ apiKey: "test" });
const response = await client.requestRaw({
method: "GET",
path: "/meta",
});
expect(response.data).toBeNull();
});
it("maps 401 and 429 errors", async () => {
const fetchMock = stubFetch();
fetchMock
.mockResolvedValueOnce(
jsonResponse({ message: "unauthorized" }, { status: 401 })
)
.mockResolvedValueOnce(
jsonResponse({ message: "rate" }, { status: 429, headers: { "retry-after": "2" } })
);
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(AuthenticationError);
const error = await client
.requestRaw({ method: "GET", path: "/meta" })
.catch((err: unknown) => err);
expect(error).toBeInstanceOf(RateLimitError);
expect((error as RateLimitError).retryAfter).toBe(2);
});
it("maps validation and upload errors", async () => {
const fetchMock = stubFetch();
fetchMock
.mockResolvedValueOnce(jsonResponse({ message: "invalid" }, { status: 422 }))
.mockResolvedValueOnce(jsonResponse({ message: "bad upload" }, { status: 400 }));
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
).rejects.toBeInstanceOf(ValidationError);
await expect(
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
).rejects.toBeInstanceOf(FileUploadError);
});
it("maps timeout and network errors", async () => {
const fetchMock = stubFetch();
fetchMock
.mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" }))
.mockRejectedValueOnce(new Error("network"));
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(TimeoutError);
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(NetworkError);
});
it("maps unknown transport failures to NetworkError", async () => {
const fetchMock = stubFetch();
fetchMock.mockRejectedValueOnce("boom");
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toMatchObject({
name: "NetworkError",
message: "Unexpected network error",
});
});
it("retries on timeout errors", async () => {
const fetchMock = stubFetch();
fetchMock
.mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" }))
.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not retry non-replayable readable request bodies", async () => {
const fetchMock = stubFetch();
fetchMock.mockRejectedValueOnce(new Error("network"));
const client = new HttpClient({ apiKey: "test", maxRetries: 2, retryDelay: 0 });
await expect(
client.requestRaw({
method: "POST",
path: "/chat-messages",
data: Readable.from(["chunk"]),
})
).rejects.toBeInstanceOf(NetworkError);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = getFetchCall(fetchMock);
expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe(
"half"
);
});
it("validates query parameters before request", async () => {
const fetchMock = stubFetch();
const client = new HttpClient({ apiKey: "test" });
await expect(
client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } })
).rejects.toBeInstanceOf(ValidationError);
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns APIError for other http failures", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse({ message: "server" }, { status: 500 }));
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(APIError);
});
it("uses plain text bodies when json parsing is not possible", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
textResponse("plain text", {
status: 200,
headers: { "content-type": "text/plain" },
})
);
const client = new HttpClient({ apiKey: "test" });
const response = await client.requestRaw({
method: "GET",
path: "/info",
});
expect(response.data).toBe("plain text");
});
it("keeps invalid json error bodies as API errors", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
textResponse("{invalid", {
status: 500,
headers: { "content-type": "application/json", "x-request-id": "req-500" },
})
);
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toMatchObject({
name: "APIError",
statusCode: 500,
requestId: "req-500",
responseBody: "{invalid",
});
});
it("sends raw string bodies without additional json encoding", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
method: "POST",
path: "/meta",
data: '{"pre":"serialized"}',
headers: { "Content-Type": "application/custom+json" },
});
const [, init] = getFetchCall(fetchMock);
expect(init?.body).toBe('{"pre":"serialized"}');
expect(toHeaderRecord(init?.headers)).toMatchObject({
"content-type": "application/custom+json",
});
});
it("preserves explicit user-agent headers", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
method: "GET",
path: "/meta",
headers: { "User-Agent": "custom-agent" },
});
const [, init] = getFetchCall(fetchMock);
expect(toHeaderRecord(init?.headers)).toMatchObject({
"user-agent": "custom-agent",
});
});
it("logs requests and responses when enableLogging is true", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 }));
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ apiKey: "test", enableLogging: true });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(
expect.stringContaining("dify-client-node response 200 GET")
);
});
it("logs retry attempts when enableLogging is true", async () => {
const fetchMock = stubFetch();
fetchMock
.mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" }))
.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({
apiKey: "test",
maxRetries: 1,
retryDelay: 0,
enableLogging: true,
});
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(
expect.stringContaining("dify-client-node retry")
);
});
});