mirror of https://github.com/langgenius/dify.git
369 lines
10 KiB
TypeScript
369 lines
10 KiB
TypeScript
import axios from "axios";
|
|
import type {
|
|
AxiosError,
|
|
AxiosInstance,
|
|
AxiosRequestConfig,
|
|
AxiosResponse,
|
|
} from "axios";
|
|
import type { Readable } from "node:stream";
|
|
import {
|
|
DEFAULT_BASE_URL,
|
|
DEFAULT_MAX_RETRIES,
|
|
DEFAULT_RETRY_DELAY_SECONDS,
|
|
DEFAULT_TIMEOUT_SECONDS,
|
|
} from "../types/common";
|
|
import type {
|
|
DifyClientConfig,
|
|
DifyResponse,
|
|
Headers,
|
|
QueryParams,
|
|
RequestMethod,
|
|
} from "../types/common";
|
|
import type { DifyError } from "../errors/dify-error";
|
|
import {
|
|
APIError,
|
|
AuthenticationError,
|
|
FileUploadError,
|
|
NetworkError,
|
|
RateLimitError,
|
|
TimeoutError,
|
|
ValidationError,
|
|
} from "../errors/dify-error";
|
|
import { getFormDataHeaders, isFormData } from "./form-data";
|
|
import { createBinaryStream, createSseStream } from "./sse";
|
|
import { getRetryDelayMs, shouldRetry, sleep } from "./retry";
|
|
import { validateParams } from "../client/validation";
|
|
|
|
const DEFAULT_USER_AGENT = "dify-client-node";
|
|
|
|
export type RequestOptions = {
|
|
method: RequestMethod;
|
|
path: string;
|
|
query?: QueryParams;
|
|
data?: unknown;
|
|
headers?: Headers;
|
|
responseType?: AxiosRequestConfig["responseType"];
|
|
};
|
|
|
|
export type HttpClientSettings = Required<
|
|
Omit<DifyClientConfig, "apiKey">
|
|
> & {
|
|
apiKey: string;
|
|
};
|
|
|
|
const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({
|
|
apiKey: config.apiKey,
|
|
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
|
|
timeout: config.timeout ?? DEFAULT_TIMEOUT_SECONDS,
|
|
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
retryDelay: config.retryDelay ?? DEFAULT_RETRY_DELAY_SECONDS,
|
|
enableLogging: config.enableLogging ?? false,
|
|
});
|
|
|
|
const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => {
|
|
const result: Headers = {};
|
|
if (!headers) {
|
|
return result;
|
|
}
|
|
Object.entries(headers).forEach(([key, value]) => {
|
|
if (Array.isArray(value)) {
|
|
result[key.toLowerCase()] = value.join(", ");
|
|
} else if (typeof value === "string") {
|
|
result[key.toLowerCase()] = value;
|
|
} else if (typeof value === "number") {
|
|
result[key.toLowerCase()] = value.toString();
|
|
}
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const resolveRequestId = (headers: Headers): string | undefined =>
|
|
headers["x-request-id"] ?? headers["x-requestid"];
|
|
|
|
const buildRequestUrl = (baseUrl: string, path: string): string => {
|
|
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
return `${trimmed}${path}`;
|
|
};
|
|
|
|
const buildQueryString = (params?: QueryParams): string => {
|
|
if (!params) {
|
|
return "";
|
|
}
|
|
const searchParams = new URLSearchParams();
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value === undefined || value === null) {
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item) => {
|
|
searchParams.append(key, String(item));
|
|
});
|
|
return;
|
|
}
|
|
searchParams.append(key, String(value));
|
|
});
|
|
return searchParams.toString();
|
|
};
|
|
|
|
const parseRetryAfterSeconds = (headerValue?: string): number | undefined => {
|
|
if (!headerValue) {
|
|
return undefined;
|
|
}
|
|
const asNumber = Number.parseInt(headerValue, 10);
|
|
if (!Number.isNaN(asNumber)) {
|
|
return asNumber;
|
|
}
|
|
const asDate = Date.parse(headerValue);
|
|
if (!Number.isNaN(asDate)) {
|
|
const diff = asDate - Date.now();
|
|
return diff > 0 ? Math.ceil(diff / 1000) : 0;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const isReadableStream = (value: unknown): value is Readable => {
|
|
if (!value || typeof value !== "object") {
|
|
return false;
|
|
}
|
|
return typeof (value as { pipe?: unknown }).pipe === "function";
|
|
};
|
|
|
|
const isUploadLikeRequest = (config?: AxiosRequestConfig): boolean => {
|
|
const url = (config?.url ?? "").toLowerCase();
|
|
if (!url) {
|
|
return false;
|
|
}
|
|
return (
|
|
url.includes("upload") ||
|
|
url.includes("/files/") ||
|
|
url.includes("audio-to-text") ||
|
|
url.includes("create_by_file") ||
|
|
url.includes("update_by_file")
|
|
);
|
|
};
|
|
|
|
const resolveErrorMessage = (status: number, responseBody: unknown): string => {
|
|
if (typeof responseBody === "string" && responseBody.trim().length > 0) {
|
|
return responseBody;
|
|
}
|
|
if (
|
|
responseBody &&
|
|
typeof responseBody === "object" &&
|
|
"message" in responseBody
|
|
) {
|
|
const message = (responseBody as Record<string, unknown>).message;
|
|
if (typeof message === "string" && message.trim().length > 0) {
|
|
return message;
|
|
}
|
|
}
|
|
return `Request failed with status code ${status}`;
|
|
};
|
|
|
|
const mapAxiosError = (error: unknown): DifyError => {
|
|
if (axios.isAxiosError(error)) {
|
|
const axiosError = error as AxiosError;
|
|
if (axiosError.response) {
|
|
const status = axiosError.response.status;
|
|
const headers = normalizeHeaders(axiosError.response.headers);
|
|
const requestId = resolveRequestId(headers);
|
|
const responseBody = axiosError.response.data;
|
|
const message = resolveErrorMessage(status, responseBody);
|
|
|
|
if (status === 401) {
|
|
return new AuthenticationError(message, {
|
|
statusCode: status,
|
|
responseBody,
|
|
requestId,
|
|
});
|
|
}
|
|
if (status === 429) {
|
|
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
|
|
return new RateLimitError(message, {
|
|
statusCode: status,
|
|
responseBody,
|
|
requestId,
|
|
retryAfter,
|
|
});
|
|
}
|
|
if (status === 422) {
|
|
return new ValidationError(message, {
|
|
statusCode: status,
|
|
responseBody,
|
|
requestId,
|
|
});
|
|
}
|
|
if (status === 400) {
|
|
if (isUploadLikeRequest(axiosError.config)) {
|
|
return new FileUploadError(message, {
|
|
statusCode: status,
|
|
responseBody,
|
|
requestId,
|
|
});
|
|
}
|
|
}
|
|
return new APIError(message, {
|
|
statusCode: status,
|
|
responseBody,
|
|
requestId,
|
|
});
|
|
}
|
|
if (axiosError.code === "ECONNABORTED") {
|
|
return new TimeoutError("Request timed out", { cause: axiosError });
|
|
}
|
|
return new NetworkError(axiosError.message, { cause: axiosError });
|
|
}
|
|
if (error instanceof Error) {
|
|
return new NetworkError(error.message, { cause: error });
|
|
}
|
|
return new NetworkError("Unexpected network error", { cause: error });
|
|
};
|
|
|
|
export class HttpClient {
|
|
private axios: AxiosInstance;
|
|
private settings: HttpClientSettings;
|
|
|
|
constructor(config: DifyClientConfig) {
|
|
this.settings = normalizeSettings(config);
|
|
this.axios = axios.create({
|
|
baseURL: this.settings.baseUrl,
|
|
timeout: this.settings.timeout * 1000,
|
|
});
|
|
}
|
|
|
|
updateApiKey(apiKey: string): void {
|
|
this.settings.apiKey = apiKey;
|
|
}
|
|
|
|
getSettings(): HttpClientSettings {
|
|
return { ...this.settings };
|
|
}
|
|
|
|
async request<T>(options: RequestOptions): Promise<DifyResponse<T>> {
|
|
const response = await this.requestRaw(options);
|
|
const headers = normalizeHeaders(response.headers);
|
|
return {
|
|
data: response.data as T,
|
|
status: response.status,
|
|
headers,
|
|
requestId: resolveRequestId(headers),
|
|
};
|
|
}
|
|
|
|
async requestStream<T>(options: RequestOptions) {
|
|
const response = await this.requestRaw({
|
|
...options,
|
|
responseType: "stream",
|
|
});
|
|
const headers = normalizeHeaders(response.headers);
|
|
return createSseStream<T>(response.data as Readable, {
|
|
status: response.status,
|
|
headers,
|
|
requestId: resolveRequestId(headers),
|
|
});
|
|
}
|
|
|
|
async requestBinaryStream(options: RequestOptions) {
|
|
const response = await this.requestRaw({
|
|
...options,
|
|
responseType: "stream",
|
|
});
|
|
const headers = normalizeHeaders(response.headers);
|
|
return createBinaryStream(response.data as Readable, {
|
|
status: response.status,
|
|
headers,
|
|
requestId: resolveRequestId(headers),
|
|
});
|
|
}
|
|
|
|
async requestRaw(options: RequestOptions): Promise<AxiosResponse> {
|
|
const { method, path, query, data, headers, responseType } = options;
|
|
const { apiKey, enableLogging, maxRetries, retryDelay, timeout } =
|
|
this.settings;
|
|
|
|
if (query) {
|
|
validateParams(query as Record<string, unknown>);
|
|
}
|
|
if (
|
|
data &&
|
|
typeof data === "object" &&
|
|
!Array.isArray(data) &&
|
|
!isFormData(data) &&
|
|
!isReadableStream(data)
|
|
) {
|
|
validateParams(data as Record<string, unknown>);
|
|
}
|
|
|
|
const requestHeaders: Headers = {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
...headers,
|
|
};
|
|
if (
|
|
typeof process !== "undefined" &&
|
|
!!process.versions?.node &&
|
|
!requestHeaders["User-Agent"] &&
|
|
!requestHeaders["user-agent"]
|
|
) {
|
|
requestHeaders["User-Agent"] = DEFAULT_USER_AGENT;
|
|
}
|
|
|
|
if (isFormData(data)) {
|
|
Object.assign(requestHeaders, getFormDataHeaders(data));
|
|
} else if (data && method !== "GET") {
|
|
requestHeaders["Content-Type"] = "application/json";
|
|
}
|
|
|
|
const url = buildRequestUrl(this.settings.baseUrl, path);
|
|
|
|
if (enableLogging) {
|
|
console.info(`dify-client-node request ${method} ${url}`);
|
|
}
|
|
|
|
const axiosConfig: AxiosRequestConfig = {
|
|
method,
|
|
url: path,
|
|
params: query,
|
|
paramsSerializer: {
|
|
serialize: (params) => buildQueryString(params as QueryParams),
|
|
},
|
|
headers: requestHeaders,
|
|
responseType: responseType ?? "json",
|
|
timeout: timeout * 1000,
|
|
};
|
|
|
|
if (method !== "GET" && data !== undefined) {
|
|
axiosConfig.data = data;
|
|
}
|
|
|
|
let attempt = 0;
|
|
// `attempt` is a zero-based retry counter
|
|
// Total attempts = 1 (initial) + maxRetries
|
|
// e.g., maxRetries=3 means: attempt 0 (initial), then retries at 1, 2, 3
|
|
while (true) {
|
|
try {
|
|
const response = await this.axios.request(axiosConfig);
|
|
if (enableLogging) {
|
|
console.info(
|
|
`dify-client-node response ${response.status} ${method} ${url}`
|
|
);
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
const mapped = mapAxiosError(error);
|
|
if (!shouldRetry(mapped, attempt, maxRetries)) {
|
|
throw mapped;
|
|
}
|
|
const retryAfterSeconds =
|
|
mapped instanceof RateLimitError ? mapped.retryAfter : undefined;
|
|
const delay = getRetryDelayMs(attempt + 1, retryDelay, retryAfterSeconds);
|
|
if (enableLogging) {
|
|
console.info(
|
|
`dify-client-node retry ${attempt + 1} in ${delay}ms for ${method} ${url}`
|
|
);
|
|
}
|
|
attempt += 1;
|
|
await sleep(delay);
|
|
}
|
|
}
|
|
}
|
|
}
|