Skip to content

Normalize request headers and query data before invoking composed interceptors #4336

@PandaWorker

Description

@PandaWorker

Description

When using Agent().compose(...) to add custom interceptors, the request’s headers and query data arrive in wildly different shapes depending on how you called request() or fetch(). Users can pass:

  • Nothing (opts.headers is undefined)
  • Array of strings (['k','v','k2','v2'])
  • Plain object ({ k: 'v', k2: 'v2' })
  • Headers instance
  • Custom iterable (e.g. a user‑defined class with [Symbol.iterator] yielding [name, value] pairs)

Because these values aren’t normalized before your composed interceptors run, every interceptor ends up having to defensively check for multiple shapes:

if (!opts.headers) {  }
else if (Array.isArray(opts.headers)) {  }
else if (opts.headers instanceof Headers) {  }
else if (typeof opts.headers[Symbol.iterator] === 'function') {  }
else if (typeof opts.headers === 'object') {  }

This bloats interceptor code and forces duplication of header‑parsing logic in every piece of middleware (auth, compression, retries, logging, etc.).

Reproduction

import { request, fetch, Agent, Headers, Dispatcher, DecoratorHandler } from 'undici';

function createCustomInterceptor(): Dispatcher.DispatcherComposeInterceptor {
  return (dispatch) => (opts, handler) => {
    console.log(`-> ${opts.method} ${opts.path}, headers`, opts.headers);

    class LoggerHandler extends DecoratorHandler {
      onResponseStart(controller, status, headers, statusText) {
        console.log(`<- ${status} ${statusText}, headers:`, headers);
        super.onResponseStart(controller, status, headers, statusText);
      }
    }

    return dispatch(opts, new LoggerHandler(handler));
  };
}

const dispatcher = new Agent().compose(createCustomInterceptor());

class CustomHeaders {
  raw = [['k','v'], ['k2','v2']];
  *entries() { for (const pair of this.raw) yield pair; }
  [Symbol.iterator]() { return this.entries(); }
}

// Try all the different ways to pass headers:
await request('https://api.ipify.org/', { dispatcher });
await request('https://api.ipify.org/', { headers: ['k','v','k2','v2'], dispatcher, responseHeaders: 'raw' });
await request('https://api.ipify.org/', { headers: { k: 'v', k2: 'v2' }, dispatcher });
await request('https://api.ipify.org/', { headers: new Headers([['k','v'],['k2','v2']]), dispatcher });
await request('https://api.ipify.org/', { headers: new CustomHeaders(), dispatcher });

await fetch  ('https://api.ipify.org/', { headers: new Headers([['k','v'],['k2','v2']]), dispatcher });
await fetch  ('https://api.ipify.org/', { headers: new CustomHeaders(), dispatcher });

Observed output:

-> GET /, headers undefined
-> GET /, headers [ 'k', 'v', 'k2', 'v2' ]
-> GET /, headers { k: 'v', k2: 'v2' }
-> GET /, headers Headers { k: 'v', k2: 'v2' }
-> GET /, headers CustomHeaders {}
…

Expected Behavior

All interceptors should receive a normalized version of:

  • opts.headers as a consistent map of lower-cased header names to string values
  • opts.query/opts.searchParams similarly normalized into a single, predictable shape

Regardless of how the caller passed them in—arrays, objects, Headers, or any iterable—the dispatcher should convert into one uniform form so that interceptors only ever have to deal with, for example, a plain object { [header: string]: string } or a Headers instance or other HeaderMap structures without validated logical.

Impact

Without dispatcher‑level normalization:

  • Boilerplate appears in every interceptor to detect and normalize header shapes.
  • Code duplication and potential for subtle bugs increase.
  • Reusable, composable middleware becomes harder to write and maintain.

Proposed Solution

  1. Create a HeadersMap structure that will transmit headers in a single format inside the core, We need a structure with methods like Headers.
  2. Pre-process opts.headers (and opts.query / opts.searchParams) in the core dispatcher before invoking any composed interceptors.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions