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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions