Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bd1691b
feat(node-http-handler): pass client logger to request handlers
aBurmeseDev Jul 21, 2026
65f4a95
Merge branch 'main' into feat/pass-client-logger-to-request-handler
aBurmeseDev Jul 21, 2026
6671898
fix: cast logger value type in updateHttpClientConfig
aBurmeseDev Jul 21, 2026
418fa74
simplify logger injection per team decision
aBurmeseDev Jul 24, 2026
687f8e3
Merge branch 'main' into feat/pass-client-logger-to-request-handler
aBurmeseDev Jul 24, 2026
a43865a
remove nullish assignments and add truthy check
aBurmeseDev Jul 24, 2026
8833c39
Merge branch 'main' into feat/pass-client-logger-to-request-handler
aBurmeseDev Jul 29, 2026
6f96ae3
feat(core): offer client logger to request handlers as a fallback
aBurmeseDev Aug 3, 2026
203c84e
test: cover fallback logger in handler specs
aBurmeseDev Aug 5, 2026
25212ad
chore: record fallbackLogger in API snapshot
aBurmeseDev Aug 5, 2026
0609b70
Merge branch 'main' into feat/pass-client-logger-to-request-handler
aBurmeseDev Aug 5, 2026
ada1408
Merge branch 'main' into feat/pass-client-logger-to-request-handler
aBurmeseDev Aug 6, 2026
d84a219
fix: read requestHandler in http handler extension config
aBurmeseDev Aug 9, 2026
6c73415
fix: drop stale api snapshot entries from bad merge
aBurmeseDev Aug 9, 2026
4f81d14
Merge branch 'main' into feat/pass-client-logger-to-request-handler
aBurmeseDev Aug 9, 2026
9f3f1ee
refactor: inline the fallback logger key in handlers
aBurmeseDev Aug 11, 2026
fc65973
refactor(core): inline the fallback logger key and drop fallbackLogge…
aBurmeseDev Aug 11, 2026
0fe930f
Merge branch 'main' into feat/pass-client-logger-to-request-handler
aBurmeseDev Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/pass-client-logger-to-request-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@smithy/core": minor
"@smithy/node-http-handler": minor
"@smithy/undici-http-handler": minor
---

feat: offer the client logger to request handlers as a fallback, without overwriting a handler's own logger. A NoOpLogger is not offered, so handlers keep their own console-based defaults.

fix: `getHttpHandlerExtensionConfiguration` and `resolveHttpHandlerRuntimeConfig` now read and write `requestHandler` instead of `httpHandler`, which is the field clients actually populate. This changes the shape of the internal `HttpHandlerExtensionConfigType` and of the object returned by `resolveHttpHandlerRuntimeConfig`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";

import { getHttpHandlerExtensionConfiguration, resolveHttpHandlerRuntimeConfig } from "./httpExtensionConfiguration";

/**
* The key under which a client offers its logger. Declared locally, as the
* handlers do: `Symbol.for` makes every copy of this key equal.
*/
const FALLBACK_LOGGER = Symbol.for("logger");

describe("getHttpHandlerExtensionConfiguration", () => {
const createMockHandler = () => ({
metadata: { handlerProtocol: "http/1.1" },
handle: vi.fn(),
updateHttpClientConfig: vi.fn(),
httpHandlerConfigs: vi.fn().mockReturnValue({}),
});

const createMockLogger = () => ({
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
});

/**
* Stands in for the client's NoOpLogger, which is detected by constructor
* name to avoid a cross-submodule import.
*/
class NoOpLogger {
public trace = vi.fn();
public debug = vi.fn();
public info = vi.fn();
public warn = vi.fn();
public error = vi.fn();
}

describe("client logger injection", () => {
it("offers the client logger to the handler under the fallback key", () => {
const handler = createMockHandler();
const logger = createMockLogger();

getHttpHandlerExtensionConfiguration({ requestHandler: handler, logger } as any);

expect(handler.updateHttpClientConfig).toHaveBeenCalledWith(FALLBACK_LOGGER, logger);
});

it("does not assign the public logger key, so an explicit handler logger is never overwritten", () => {
const handler = createMockHandler();
const logger = createMockLogger();

getHttpHandlerExtensionConfiguration({ requestHandler: handler, logger } as any);

expect(handler.updateHttpClientConfig).not.toHaveBeenCalledWith("logger", expect.anything());
expect(handler.updateHttpClientConfig).toHaveBeenCalledTimes(1);
});

it("does not call updateHttpClientConfig when logger is not set", () => {
const handler = createMockHandler();

getHttpHandlerExtensionConfiguration({ requestHandler: handler } as any);

expect(handler.updateHttpClientConfig).not.toHaveBeenCalled();
});

it("does not offer a NoOpLogger, so handlers keep their console defaults", () => {
const handler = createMockHandler();

getHttpHandlerExtensionConfiguration({ requestHandler: handler, logger: new NoOpLogger() } as any);

expect(handler.updateHttpClientConfig).not.toHaveBeenCalled();
});

it("does not throw when no handler is present", () => {
const logger = createMockLogger();

// should not throw
getHttpHandlerExtensionConfiguration({ logger } as any);
});

it("does not throw for a handler that predates updateHttpClientConfig", () => {
const logger = createMockLogger();
const legacyHandler = { metadata: {}, handle: vi.fn() };

// should not throw
getHttpHandlerExtensionConfiguration({ requestHandler: legacyHandler, logger } as any);
});
});

describe("handler accessors read and write requestHandler", () => {
it("returns the handler that clients populate as requestHandler", () => {
const handler = createMockHandler();

const extension = getHttpHandlerExtensionConfiguration({ requestHandler: handler } as any);

expect(extension.httpHandler()).toBe(handler);
});

it("setHttpHandler replaces the handler seen by the runtime config", () => {
const handler = createMockHandler();
const replacement = createMockHandler();
const runtimeConfig = { requestHandler: handler } as any;

const extension = getHttpHandlerExtensionConfiguration(runtimeConfig);
extension.setHttpHandler(replacement as any);

expect(runtimeConfig.requestHandler).toBe(replacement);
expect(extension.httpHandler()).toBe(replacement);
});

it("forwards updateHttpClientConfig and httpHandlerConfigs to the handler", () => {
const handler = createMockHandler();

const extension = getHttpHandlerExtensionConfiguration({ requestHandler: handler } as any);
extension.updateHttpClientConfig("requestTimeout" as any, 1000 as any);
extension.httpHandlerConfigs();

expect(handler.updateHttpClientConfig).toHaveBeenCalledWith("requestTimeout", 1000);
expect(handler.httpHandlerConfigs).toHaveBeenCalled();
});
});
});

describe("resolveHttpHandlerRuntimeConfig", () => {
it("emits the handler under requestHandler, matching what clients read", () => {
const handler = { metadata: {}, handle: vi.fn() } as any;

const runtimeConfig = resolveHttpHandlerRuntimeConfig({
httpHandler: () => handler,
} as any);

expect(runtimeConfig).toEqual({ requestHandler: handler });
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Logger } from "@smithy/types";

import type { HttpHandler } from "../httpHandler";

/**
Expand All @@ -14,29 +16,44 @@ export interface HttpHandlerExtensionConfiguration<HandlerConfig extends object
* @internal
*/
export type HttpHandlerExtensionConfigType<HandlerConfig extends object = {}> = Partial<{
httpHandler: HttpHandler<HandlerConfig>;
requestHandler: HttpHandler<HandlerConfig>;
}>;

/**
* Helper function to resolve default extension configuration from runtime config
*
* @internal
*/
export const getHttpHandlerExtensionConfiguration = <HandlerConfig extends object = {}>(
runtimeConfig: HttpHandlerExtensionConfigType<HandlerConfig>
export const getHttpHandlerExtensionConfiguration = <HandlerConfig extends { logger?: Logger }>(
runtimeConfig: HttpHandlerExtensionConfigType<HandlerConfig> & { logger?: Logger }
) => {
// Offer the client's logger under `Symbol.for("logger")`. A symbol keeps this
// off the handlers' public options types, and being a symbol already
// distinguishes it from the `"logger"` string key. `Symbol.for` means each
// handler can declare its own copy of the key and still compare equal to it.
//
// Offered as a fallback only: the handler keeps its own logger if it has one.
// A NoOpLogger is not offered at all, so that handlers fall through to their
// own console-based defaults instead of being silenced.
if (runtimeConfig.logger && runtimeConfig.logger.constructor?.name !== "NoOpLogger") {
runtimeConfig.requestHandler?.updateHttpClientConfig?.(
Symbol.for("logger") as unknown as keyof HandlerConfig,
runtimeConfig.logger as HandlerConfig[keyof HandlerConfig]
);
}

return {
setHttpHandler(handler: HttpHandler<HandlerConfig>): void {
runtimeConfig.httpHandler = handler;
runtimeConfig.requestHandler = handler;
},
httpHandler(): HttpHandler<HandlerConfig> {
return runtimeConfig.httpHandler!;
return runtimeConfig.requestHandler!;
},
updateHttpClientConfig(key: keyof HandlerConfig, value: HandlerConfig[typeof key]): void {
runtimeConfig.httpHandler?.updateHttpClientConfig(key, value);
runtimeConfig.requestHandler?.updateHttpClientConfig(key, value);
},
httpHandlerConfigs(): HandlerConfig {
return runtimeConfig.httpHandler!.httpHandlerConfigs();
return runtimeConfig.requestHandler!.httpHandlerConfigs();
},
};
};
Expand All @@ -50,6 +67,6 @@ export const resolveHttpHandlerRuntimeConfig = <HandlerConfig extends object = {
httpHandlerExtensionConfiguration: HttpHandlerExtensionConfiguration<HandlerConfig>
): HttpHandlerExtensionConfigType<HandlerConfig> => {
return {
httpHandler: httpHandlerExtensionConfiguration.httpHandler(),
requestHandler: httpHandlerExtensionConfiguration.httpHandler(),
};
};
93 changes: 93 additions & 0 deletions packages/node-http-handler/src/node-http-handler.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import http from "node:http";
import https from "node:https";
import { HttpRequest } from "@smithy/core/protocols";
import type { NodeHttpHandlerOptions } from "@smithy/types";
import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest";

import { NodeHttpHandler } from "./node-http-handler";
import * as setConnectionTimeoutModule from "./set-connection-timeout";
import * as setRequestTimeoutModule from "./set-request-timeout";
import * as setSocketTimeoutModule from "./set-socket-timeout";

// Matches the key the client offers its logger under. `Symbol.for` makes this
// the same symbol the handler compares against.
const FALLBACK_LOGGER = Symbol.for("logger") as unknown as keyof NodeHttpHandlerOptions;
import { timing } from "./timing";

let { request: hRequest } = http;
Expand Down Expand Up @@ -485,6 +490,94 @@ describe("NodeHttpHandler", () => {
});
});

describe("updateHttpClientConfig", () => {
const createLogger = () => ({ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() });

/**
* @returns the logger the handler resolved for the request, observed via
* setRequestTimeout, which receives it as its last argument.
*/
const getEffectiveLogger = async (handler: NodeHttpHandler) => {
const spy = vi.spyOn(setRequestTimeoutModule, "setRequestTimeout");
spy.mockClear();
const request = new HttpRequest({ hostname: "localhost", method: "GET", protocol: "https:", path: "/" });
try {
await handler.handle(request);
} catch {
// ignore request errors
}
return spy.mock.calls[0][4];
};

it("updates non-logger keys", async () => {
const handler = new NodeHttpHandler({ requestTimeout: 1000 });
handler.updateHttpClientConfig("requestTimeout", 5000);

const request = new HttpRequest({ hostname: "localhost", method: "GET", protocol: "https:", path: "/" });
try {
await handler.handle(request);
} catch {
// ignore request errors
}

const configs = handler.httpHandlerConfigs();
expect(configs.requestTimeout).toBe(5000);
});

it("uses the fallback logger when the handler has no logger of its own", async () => {
const handler = new NodeHttpHandler();
const clientLogger = createLogger();

handler.updateHttpClientConfig(FALLBACK_LOGGER, clientLogger);

expect(await getEffectiveLogger(handler)).toBe(clientLogger);
});

it("keeps the handler's explicit logger instead of the fallback logger", async () => {
const handlerLogger = createLogger();
const handler = new NodeHttpHandler({ logger: handlerLogger });
const clientLogger = createLogger();

handler.updateHttpClientConfig(FALLBACK_LOGGER, clientLogger);

expect(await getEffectiveLogger(handler)).toBe(handlerLogger);
});

it("accepts the fallback logger synchronously while config resolves asynchronously", async () => {
let resolveOptions: (o: NodeHttpHandlerOptions) => void;
const handler = new NodeHttpHandler(
() => new Promise<NodeHttpHandlerOptions>((resolve) => (resolveOptions = resolve))
);
const clientLogger = createLogger();

// Offered before the handler's own config has resolved.
handler.updateHttpClientConfig(FALLBACK_LOGGER, clientLogger);
resolveOptions!({});

expect(await getEffectiveLogger(handler)).toBe(clientLogger);
});

it("keeps an explicit logger that resolves asynchronously, over the fallback logger", async () => {
const handlerLogger = createLogger();
const handler = new NodeHttpHandler(async () => ({ logger: handlerLogger }));
const clientLogger = createLogger();

handler.updateHttpClientConfig(FALLBACK_LOGGER, clientLogger);

expect(await getEffectiveLogger(handler)).toBe(handlerLogger);
});

it("stores the fallback logger under the handler's own logger key", async () => {
const handler = new NodeHttpHandler();
const clientLogger = createLogger();

handler.updateHttpClientConfig(FALLBACK_LOGGER, clientLogger);
await getEffectiveLogger(handler);

expect(handler.httpHandlerConfigs().logger).toBe(clientLogger);
});
});

describe("checkSocketUsage", () => {
beforeEach(() => {
vi.spyOn(console, "warn").mockImplementation(vi.fn() as any);
Expand Down
17 changes: 11 additions & 6 deletions packages/node-http-handler/src/node-http-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ or increase socketAcquisitionWarningTimeout=(millis) in the NodeHttpHandler conf
}

const config = this.config!;
const logger = config.logger;

// determine which http(s) client to use
const isSSL = request.protocol === "https:";
Expand Down Expand Up @@ -208,11 +209,7 @@ or increase socketAcquisitionWarningTimeout=(millis) in the NodeHttpHandler conf
// This warning will be cancelled if the request resolves.
socketWarningTimeoutId = timing.setTimeout(
() => {
this.socketWarningTimestamp = NodeHttpHandler.checkSocketUsage(
agent!,
this.socketWarningTimestamp,
config.logger
);
this.socketWarningTimestamp = NodeHttpHandler.checkSocketUsage(agent!, this.socketWarningTimestamp, logger);
},
config.socketAcquisitionWarningTimeout ?? (config.requestTimeout ?? 2000) + (config.connectionTimeout ?? 1000)
);
Expand Down Expand Up @@ -298,7 +295,7 @@ or increase socketAcquisitionWarningTimeout=(millis) in the NodeHttpHandler conf
reject,
effectiveRequestTimeout,
config.throwOnRequestTimeout,
config.logger ?? console
logger ?? console
);
socketTimeoutId = setSocketTimeout(req, reject, config.socketTimeout);

Expand All @@ -325,6 +322,14 @@ or increase socketAcquisitionWarningTimeout=(millis) in the NodeHttpHandler conf
public updateHttpClientConfig(key: keyof NodeHttpHandlerOptions, value: NodeHttpHandlerOptions[typeof key]): void {
this.config = undefined;
this.configProvider = this.configProvider.then((config) => {
if ((key as unknown) === Symbol.for("logger")) {
// A client offers its logger under this key: take it only if this
// handler has no logger of its own.
return {
...config,
logger: config.logger ?? (value as Logger),
};
}
return {
...config,
[key]: value,
Expand Down
Loading