Repository navigation
undici 8.x: Header field "content-type" must only have a single value #5175
Copy link
Copy link
Closed
Labels
bugSomething isn't workingSomething isn't working
Description
Bug Description
I have update undici from 7.25.0 to 8.2.0 and now I have those errors:
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)
Reproducible By
The same code works on 7.25.0, doesn't works on 8.2.0:
const cl = createClient({
origin: 'https://httpbin.org',
});
cl.post('https://httpbin.org/post', { test: true }, {
headers: {
'Content-Type': 'application/json',
},
headersTimeout: 15000,
}).then(console.log).catch(console.error)createClient is a my utility come from:
/* 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);Expected Behavior
should works :D
Environment
NodeJS 24 and NodeJS 25
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't working
Activity
styfle commentedon May 4, 2026
This looks related to this Node.js bug:
Probably need to upgrade to Node.js 24.15.0 or later and set
strictSingleValueFields: false