Skip to content

undici 8.x: Header field "content-type" must only have a single value #5175

@cesco69

Description

@cesco69

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions