TypeError [ERR_HTTP2_HEADER_SINGLE_VALUE]: Header field \"content-type\" must only have a single value
at processHeader (node:internal/http2/util:799:15)\n at buildNgHeaderString (node:internal/http2/util:846:7)
at prepareRequestHeadersObject (node:internal/http2/util:736:23)\n at ClientHttp2Session.request (node:internal/http2/core:1842:11)
at writeH2 (/opt/backend/node_modules/.pnpm/undici@8.1.0/node_modules/undici/lib/dispatcher/client-h2.js:669:22)
at Object.write (/opt/backend/node_modules/.pnpm/undici@8.1.0/node_modules/undici/lib/dispatcher/client-h2.js:157:14)
at _resume (/opt/backend/node_modules/.pnpm/undici@8.1.0/node_modules/undici/lib/dispatcher/client.js:656:50)
at resume (/opt/backend/node_modules/.pnpm/undici@8.1.0/node_modules/undici/lib/dispatcher/client.js:574:3)
at Client.<computed> (/opt/backend/node_modules/.pnpm/undici@8.1.0/node_modules/undici/lib/dispatcher/client.js:293:31)
at /opt/backend/node_modules/.pnpm/undici@8.1.0/node_modules/undici/lib/dispatcher/client.js:518:22
at TLSSocket.<anonymous> (/opt/backend/node_modules/.pnpm/undici@8.1.0/node_modules/undici/lib/core/connect.js:120:11)
at Object.onceWrapper (node:events:630:28)\n at TLSSocket.emit (node:events:509:20)
at TLSSocket.onConnectSecure (node:internal/tls/wrap:1692:8)\n at TLSSocket.emit (node:events:509:20)
at TLSSocket._finishInit (node:internal/tls/wrap:1101:8)\n at ssl.onhandshakedone (node:internal/tls/wrap:887:12)
/* istanbul ignore file */
import { request, Dispatcher, Pool, interceptors } from 'undici';
import { IncomingHttpHeaders } from 'undici/types/header';
/**
*
*/
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
type UndiciOptions<TOpaque = null> = Omit<Dispatcher.RequestOptions<TOpaque>, 'origin' | 'path' | 'method'> & Partial<Pick<Dispatcher.RequestOptions, 'method'>>;
/**
* validateStatus
*/
const validateStatus = (status: number): boolean => status >= 200 && status < 300;
type HttpClientOptions<TOpaque = null> = {
origin: string;
validateStatus: (status: number) => boolean;
} & UndiciOptions<TOpaque>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HttpClientResponse<T = any> = { data: T; status: number; headers: IncomingHttpHeaders };
const HEADERS_TIMEOUT = 15_000;
const BODY_TIMEOUT = 15_000;
const MAX_REDIRECTS = 3;
export class HttpClient {
private readonly pool!: Pool | Dispatcher;
private readonly options: Partial<HttpClientOptions> | undefined;
constructor(options?: Partial<HttpClientOptions>) {
this.options = options;
this.options ??= {};
if (this.options.origin) {
this.pool = new Pool(this.options.origin, {
pipelining: 10,
connectTimeout: 5_000,
clientTtl: 30 * 1000,
headersTimeout: this.options?.headersTimeout ?? HEADERS_TIMEOUT,
bodyTimeout: this.options?.bodyTimeout ?? BODY_TIMEOUT,
}).compose(
interceptors.dns({
affinity: 4,
})
);
}
this.options = {
...this.options,
blocking: false,
reset: false,
headersTimeout: this.options?.headersTimeout ?? HEADERS_TIMEOUT,
bodyTimeout: this.options?.bodyTimeout ?? BODY_TIMEOUT,
headers: {
'user-agent': 'my-app',
'accept-encoding': 'identity',
...options?.headers,
},
validateStatus: validateStatus,
};
}
/**
* GET
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get<T = any>(url: string, options?: Partial<HttpClientOptions>): Promise<HttpClientResponse<T>> {
return this.followRedirects<T>('GET', url, undefined, options);
}
/**
* POST
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
post<T = any, K = unknown>(url: string, body: K, options?: Partial<HttpClientOptions>): Promise<HttpClientResponse<T>> {
return this.followRedirects<T, K>('POST', url, body, options);
}
/**
* DELETE
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete<T = any, K = unknown>(url: string, body: K, options?: Partial<HttpClientOptions>): Promise<HttpClientResponse<T>> {
return this.followRedirects<T, K>('DELETE', url, body, options);
}
/**
* POST con retry e backoff esponenziale.
*
* @param url - URL della richiesta
* @param body - Body della richiesta
* @param options - Opzioni aggiuntive per la richiesta
* @param maxRetries - Numero massimo di tentativi (default: 5)
* @param baseDelayMs - Delay base in ms per il backoff (default: 1000)
* @returns Risposta della chiamata
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async postWithRetry<T = any, K = unknown>(url: string, body: K, options?: Partial<HttpClientOptions>, maxRetries: number = 5, baseDelayMs: number = 1000): Promise<HttpClientResponse<T>> {
let lastError: Error | null = null;
let lastResponse: HttpClientResponse<T> | null = null;
const retryableStatusCodes = [408, 429, 503, 504];
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// eslint-disable-next-line no-await-in-loop
const response = await this.post<T, K>(url, body, options);
if (retryableStatusCodes.includes(response.status) && attempt < maxRetries) {
lastResponse = response;
const delayMs = baseDelayMs * Math.pow(2, attempt);
// eslint-disable-next-line no-await-in-loop
await sleep(delayMs);
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries) {
const delayMs = baseDelayMs * Math.pow(2, attempt);
// eslint-disable-next-line no-await-in-loop
await sleep(delayMs);
}
}
}
if (lastResponse) return lastResponse;
if (lastError) throw lastError;
throw new Error('Nessun errore');
}
/**
* Segue i redirect HTTP in modo conforme a RFC 7231.
*
* Esegue la richiesta tramite `this.raw` e segue i redirect 3xx con header `Location`
* in un loop, fino a ricevere una risposta non-3xx o superare il limite massimo.
*
* @param method - Metodo HTTP iniziale
* @param url - URL della richiesta
* @param body - Body della richiesta (opzionale)
* @param options - Opzioni aggiuntive per la richiesta
* @returns La risposta finale dopo aver seguito tutti i redirect
* @throws {ServiceError} Se il numero di redirect supera `MAX_REDIRECTS`
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async followRedirects<T = any, K = unknown>(method: Dispatcher.HttpMethod, url: string, body?: K, options?: Partial<HttpClientOptions>): Promise<HttpClientResponse<T>> {
let currentUrl = url;
const currentMethod = method;
const currentBody: K | undefined = body;
let redirectCount = 0;
let resp = await this.raw<T, K>(currentMethod, currentUrl, currentBody, options);
while (resp.status >= 300 && resp.status < 400 && resp.headers.location) {
redirectCount++;
if (redirectCount > MAX_REDIRECTS) {
throw new Error('Too many redirects');
}
const location = resp.headers.location as string;
currentUrl = new URL(location, currentUrl).toString();
// 307/308: mantiene metodo e body originali (nessuna modifica necessaria)
// eslint-disable-next-line no-await-in-loop
resp = await this.raw<T, K>(currentMethod, currentUrl, currentBody, options);
}
return resp;
}
/**
* RAW
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async raw<T = any, K = unknown, TOpaque = null>(method: Dispatcher.HttpMethod, url: string, body?: K, options?: Partial<HttpClientOptions>): Promise<HttpClientResponse<T>> {
const _options = {
...this.options,
...options,
} as UndiciOptions<TOpaque>;
if (typeof body === 'object') {
_options.headers = {
..._options.headers,
'content-type': 'application/json',
};
_options.body = JSON.stringify(body);
} else if (body) {
_options.body = body as string;
}
_options.method = method;
let response;
let data = null as T;
try {
if (this.pool && this.options?.origin && url.startsWith(this.options.origin)) {
const path = url.substring(this.options.origin.length);
response = await this.pool.request<TOpaque>({ ..._options, path } as Dispatcher.RequestOptions<TOpaque>);
} else {
response = await request<TOpaque>(url, _options);
}
if (typeof options?.validateStatus === 'function' && !options.validateStatus(response.statusCode)) {
throw new Error(`Response from "${url}" with statusCode "${response.statusCode}"`);
}
const type = response.headers['content-type'] ?? '';
if (type.includes('json')) {
data = (await response.body.json()) as T;
} else if (type.includes('text')) {
data = (await response.body.text()) as T;
} else if (type.includes('binary') || type.includes('octet-stream')) {
data = (await response.body.blob()) as T;
} else {
// socket leak: socket non rilasciato se non si consuma il body
await response.body.dump?.();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (cause: any) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
cause.additionalInfo = {
method,
url,
status: response?.statusCode ?? null,
headers: response?.headers ?? null,
requestBody: _options.body ?? null,
responseBody: data ?? null,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
connectTimeout: (_options as any).connectTimeout ?? null,
headersTimeout: _options.headersTimeout ?? null,
bodyTimeout: _options.bodyTimeout ?? null,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
throw new Error(`${method} ${url} error ${cause?.message ?? 'unknown error'}`, { cause });
}
return { data, status: response.statusCode, headers: response.headers };
}
}
/**
* Restituisce un client HTTP preconfigurato
*/
export const createClient = (options?: Partial<HttpClientOptions>): HttpClient => new HttpClient(options);
Bug Description
I have update undici from 7.25.0 to 8.2.0 and now I have those errors:
Reproducible By
The same code works on 7.25.0, doesn't works on 8.2.0:
createClientis a my utility come from:Expected Behavior
should works :D
Environment
NodeJS 24 and NodeJS 25