Skip to content

feat(undici-http-handler): add HttpHandler backed by Node.js undici#2029

Open
trivikr wants to merge 26 commits into
mainfrom
undici-http-handler
Open

feat(undici-http-handler): add HttpHandler backed by Node.js undici#2029
trivikr wants to merge 26 commits into
mainfrom
undici-http-handler

Conversation

@trivikr
Copy link
Copy Markdown
Contributor

@trivikr trivikr commented May 14, 2026

Issue #, if available:

Migrating code from test package in https://github.com/trivikr/trivikr-test-undici-http-handler

Description of changes:

Adds a new @smithy/undici-http-handler package that provides a Smithy-compatible HTTP handler backed by undici instead of Node.js native http/https modules.

undici offers improved HTTP performance over Node.js built-in modules. Benchmarks show 20%-30% less time spent in request handling compared to NodeHttpHandler from @smithy/node-http-handler, particularly under concurrent load.

Benchmarks (in several runs, it's close to 1.20x and 1.30x)

$ undici-http-handler> yarn test:bench
...
 ✓ src/undici-http-handler.bench.ts > 10 sequential GETs 1213ms
     name                   hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · NodeHttpHandler    413.26  2.0419  5.2733  2.4198  2.3764  3.9081  5.0611  5.2733  ±2.49%      207
   · UndiciHttpHandler  476.21  1.8400  4.0838  2.0999  2.1102  2.9196  3.0122  4.0838  ±1.55%      239

 ✓ src/undici-http-handler.bench.ts > 50 concurrent GETs 1267ms
     name                   hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · NodeHttpHandler    208.81  4.2706  7.0158  4.7890  4.8566  6.7310  7.0158  7.0158  ±2.30%      105
   · UndiciHttpHandler  274.86  3.3944  4.3799  3.6383  3.6770  4.2547  4.3799  4.3799  ±0.87%      138

 BENCH  Summary

  UndiciHttpHandler - src/undici-http-handler.bench.ts > 10 sequential GETs
    1.15x faster than NodeHttpHandler

  UndiciHttpHandler - src/undici-http-handler.bench.ts > 50 concurrent GETs
    1.32x faster than NodeHttpHandler

The E2E tests were run on test handler in aws/aws-sdk-js-v3#8014
New E2E tests will be added when @smithy/undici-http-handler is published.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@trivikr trivikr requested a review from a team as a code owner May 14, 2026 19:00
Comment thread packages/undici-http-handler/src/undici-http-handler.spec.ts Dismissed
@trivikr trivikr force-pushed the undici-http-handler branch from 1e62635 to 3c321e1 Compare May 14, 2026 19:01
@trivikr trivikr force-pushed the undici-http-handler branch from e87cecf to 450e88b Compare May 14, 2026 20:17
@trivikr
Copy link
Copy Markdown
Contributor Author

trivikr commented May 14, 2026

There are a lot of issues with monorepo setup which blocks us from using private fields in UndiciHttpHandler.
I'll switch to using TypeScript private member. We can switch to private fields in a major version bump when dropping support for Node.js version in future.

"typedoc": "0.23.23"
},
"peerDependencies": {
"undici": "^6.0.0"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should only appear in one of deps and peerDeps, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A module, when it appears in both dependencies and peerDependencies within a package.json file, it usually means a default fallback for the dependency.

When we specify undici@^6.0.0 in dependencies, that mean it's the default. When we specify undici@^6.0.0 in peerDepedencies, it means customers can use those versions when passing their custom dispatcher.

The handler as it's currently designed will accept 7.x and 8.x versions of undici too, if customers on newer versions of Node.js and want to avail performance benefits.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered removing undici from dependencies and asking customers to provide a dispatcher.
But customers will have to import both the handler and undici, which won't be a good minimal experience.

Existing minimal code

import { S3 } from "@aws-sdk/client-s3";
import { UndiciHttpHandler } from "@smithy/undici-http-handler";

const client = new S3({
  requestHandler: new UndiciHttpHandler(),
});

Minimal code if we require customer to pass dispatcher

import { S3 } from "@aws-sdk/client-s3";
import { UndiciHttpHandler } from "@smithy/undici-http-handler";
import { Agent } from "undici";

const client = new S3({
  requestHandler: new UndiciHttpHandler({ dispatcher: new Agent() }),
});

I think the former is better.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, so should the one in peerDep be stated as >=6.0.0 then?

Comment thread packages/undici-http-handler/src/undici-http-handler.bench.ts
Comment thread packages/undici-http-handler/src/undici-http-handler.ts Outdated
Comment thread packages/undici-http-handler/src/undici-http-handler.ts Outdated
public destroy(): void {
if (this.config.dispatcher && !this.externalDispatcher) {
this.config.dispatcher.destroy();
this.config.dispatcher = undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if the user intent/expectation is that the destroy method destroys their dispatcher too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default, we should not destroy the dispatcher as it's external.
But I can also see that most of the customers would likely create dispatcher specifically for the client.

I would like to keep this behavior, and make a change in major version bump is customers request it.
Their existing code, which destroys the custom dispatcher would be backward compatible.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update: I realized that we destroy the user provided agent in node-http-handler, so it would make sense to also do so here

ref:

public destroy(): void {
this.config?.httpAgent?.destroy();
this.config?.httpsAgent?.destroy();
}

Comment thread packages/undici-http-handler/src/undici-http-handler.ts Outdated
Comment thread packages/undici-http-handler/src/undici-http-handler.ts Outdated
}

public httpHandlerConfigs(): UndiciHttpHandlerOptions {
return { ...this.config };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why copy here? in node http2 I see return this.config ?? {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't copy, the callee can mutate dispatcher/logger directly and can bypass the intended runtime encapsulation. We should update node http2 handler implementation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the return type be marked Readonly<UndiciHttpHandlerOptions>?

Comment thread packages/undici-http-handler/README.md
const contents = fs.readFileSync(file);

if (file.endsWith(".spec.ts")) {
if (file.endsWith(".spec.ts") || file.endsWith(".bench.ts")) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we move the bench to /tests it wouldn't need this exception, as only src is walked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be okay, since .bench.ts is a recommended standard for naming benchmark files in vitest.
And we're likely going to use the recommendation for future benchmarks.

Historically, we've use test folder only for clients as we overwrite the src folder which is code generated. For non-clients modules, we tend to keep tests as close to the source as possible.

I'm open to naming benchmark files as *.bench.spec.ts, but that would be against the recommendation from vitest. Do you have a preference?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leaving .bench.ts here is fine, is there some way to globally exclude the pattern from compilation into dist-cjs/es/types?

@kuhe kuhe changed the title feat: add HttpHandler backed by Node.js undici feat(undici-http-handler): add HttpHandler backed by Node.js undici May 15, 2026
@trivikr trivikr force-pushed the undici-http-handler branch from 2ac220f to 8c3a032 Compare May 15, 2026 19:09
// Uses the same duck-typing check as undici's isStream (pipe + on).
const body = request.body as Readable | undefined;
if (body && typeof body.pipe === "function" && typeof body.on === "function") {
if (headers["transfer-encoding"] === "chunked") delete headers["transfer-encoding"];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http headers are case insensitive entirely, things like EXPECT and Transfer-encoding will slip through here, is it ok?

}
}
if (request.fragment) {
path += `#${request.fragment}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be appended and sent over the wire? reading some RFCs around this here (9110, 3986, 9112) it seems like the server could reject it or ignore it.

Some protocol elements that refer to a URI allow inclusion of a fragment, while others do not.

Note: The fragment identifier component is not part of the scheme definition for a URI scheme....

and it does not seem to be a part of the request-targets either (RFC 9112)

for (const key in responseHeaders) {
const value = responseHeaders[key];
if (value !== undefined) {
transformedHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
Copy link
Copy Markdown
Contributor

@siddsriv siddsriv May 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this transformation need to be unconditional? we might have some perf gains here: most headers might not require this transformation, they should be strings. We might not need to make a new allocation and x iterations

we could check if the value is indeed an array otherwise skip this copying. i'm hoping the multi-value-header case is indeed rare in which case this could be optimized further

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants