mirror of
https://github.com/langgenius/dify.git
synced 2026-07-22 19:48:36 +08:00
512 lines
16 KiB
TypeScript
512 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 getDuplex = (init: RequestInit | undefined) =>
|
|
init && 'duplex' in init ? init.duplex : 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(getDuplex(init)).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(getDuplex(init)).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(getDuplex(init)).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'))
|
|
})
|
|
})
|