Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Readable } from "stream";
import { Agent, request } from "undici";
import { HttpWaitStrategy } from "./http-wait-strategy";

// Hoisted so it is initialised before the mock factory below runs.
const { agentInstances } = vi.hoisted(() => ({ agentInstances: [] as import("undici").Agent[] }));

// Spy on the Agent constructor and stub `request` so the tests run without real HTTP.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
Agent: vi.fn(function (...args: ConstructorParameters<typeof actual.Agent>) {
const agent = new actual.Agent(...args);
agentInstances.push(agent);
return agent;
}),
request: vi.fn(),
};
});

vi.mock("../container-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("../container-runtime")>();
return {
...actual,
getContainerRuntimeClient: async () => ({
info: { containerRuntime: { host: "localhost" } },
container: { inspect: vi.fn() },
}),
};
});

const boundPorts = { getBinding: () => 12345 } as never;
const container = { id: "container-id" } as never;

const passingResponse = () =>
({ statusCode: 200, headers: {}, body: Readable.from(["ok"]) }) as unknown as Awaited<ReturnType<typeof request>>;

// Sequential: the tests share the module-level Agent spy and instance list.
describe.sequential("HttpWaitStrategy insecure agent lifecycle", () => {
beforeEach(() => {
agentInstances.length = 0;
vi.mocked(Agent).mockClear();
vi.mocked(request).mockReset();
});

it("constructs a single insecure agent across retries and disposes it on completion", async () => {
let attempts = 0;
vi.mocked(request).mockImplementation(async () => {
attempts++;
// Fail the first attempts so the retry loop runs more than once.
if (attempts < 3) {
throw new Error("connection refused");
}
return passingResponse();
});

const strategy = new HttpWaitStrategy("/health", 8443, {})
.usingTls()
.allowInsecure()
.withReadTimeout(10)
.withStartupTimeout(5000);

await strategy.waitUntilReady(container, boundPorts);

expect(attempts).toBeGreaterThan(1);
expect(vi.mocked(Agent)).toHaveBeenCalledTimes(1);
expect(agentInstances).toHaveLength(1);
expect(agentInstances[0].destroyed).toBe(true);
});

it("creates a separate insecure agent per wait so concurrent waits stay isolated", async () => {
vi.mocked(request).mockImplementation(async () => passingResponse());

const strategy = new HttpWaitStrategy("/health", 8443, {})
.usingTls()
.allowInsecure()
.withReadTimeout(10)
.withStartupTimeout(5000);

await Promise.all([strategy.waitUntilReady(container, boundPorts), strategy.waitUntilReady(container, boundPorts)]);

expect(vi.mocked(Agent)).toHaveBeenCalledTimes(2);
expect(agentInstances).toHaveLength(2);
expect(agentInstances.every((agent) => agent.destroyed)).toBe(true);
});

it("never constructs an agent when allowInsecure is not set", async () => {
vi.mocked(request).mockImplementation(async () => passingResponse());

const strategy = new HttpWaitStrategy("/health", 8080, {}).withReadTimeout(10).withStartupTimeout(5000);

await strategy.waitUntilReady(container, boundPorts);

expect(vi.mocked(Agent)).not.toHaveBeenCalled();
expect(agentInstances).toHaveLength(0);
});
});
127 changes: 69 additions & 58 deletions packages/testcontainers/src/wait-strategies/http-wait-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
return this;
}

public withReadTimeout(startupTimeoutMs: number): this {
this.readTimeoutMs = startupTimeoutMs;
public withReadTimeout(readTimeoutMs: number): this {
this.readTimeoutMs = readTimeoutMs;
return this;
}

Expand All @@ -79,61 +79,70 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
let containerExited = false;
const client = await getContainerRuntimeClient();
const { abortOnContainerExit } = this.options;
// Scoped per invocation: one strategy instance can drive concurrent waits, so a shared
// agent would let one finished wait destroy a dispatcher another is still using.
const agent = this.createInsecureAgent();

await new IntervalRetry<Response | undefined, Error>(this.readTimeoutMs).retryUntil(
async () => {
try {
const url = `${this.protocol}://${client.info.containerRuntime.host}:${boundPorts.getBinding(this.port)}${
this.path
}`;

if (abortOnContainerExit) {
const containerStatus = (await client.container.inspect(container)).State.Status;

if (containerStatus === exitStatus) {
containerExited = true;
return;
try {
await new IntervalRetry<Response | undefined, Error>(this.readTimeoutMs).retryUntil(
async () => {
try {
const url = `${this.protocol}://${client.info.containerRuntime.host}:${boundPorts.getBinding(this.port)}${
this.path
}`;

if (abortOnContainerExit) {
const containerStatus = (await client.container.inspect(container)).State.Status;

if (containerStatus === exitStatus) {
containerExited = true;
return;
}
}

return undiciResponseToFetchResponse(
await request(url, {
method: this.method,
signal: AbortSignal.timeout(this.readTimeoutMs),
headers: this.headers,
dispatcher: agent,
})
);
} catch {
return undefined;
}
},
async (response) => {
if (abortOnContainerExit && containerExited) {
return true;
}

return undiciResponseToFetchResponse(
await request(url, {
method: this.method,
signal: AbortSignal.timeout(this.readTimeoutMs),
headers: this.headers,
dispatcher: this.getAgent(),
})
);
} catch {
return undefined;
}
},
async (response) => {
if (abortOnContainerExit && containerExited) {
return true;
}

if (response === undefined) {
return false;
} else if (!this.predicates.length) {
return response.ok;
} else {
for (const predicate of this.predicates) {
const result = await predicate(response);
if (!result) {
return false;
if (response === undefined) {
return false;
} else if (!this.predicates.length) {
return response.ok;
} else {
for (const predicate of this.predicates) {
const result = await predicate(response);
if (!result) {
return false;
}
}
return true;
}
return true;
}
},
() => {
const message = `URL ${this.path} not accessible after ${this.startupTimeoutMs}ms`;
log.error(message, { containerId: container.id });
throw new Error(message);
},
this.startupTimeoutMs
);
},
() => {
const message = `URL ${this.path} not accessible after ${this.startupTimeoutMs}ms`;
log.error(message, { containerId: container.id });
throw new Error(message);
},
this.startupTimeoutMs
);
} finally {
// Force-close: status-only predicates never read the body, so a graceful close() could
// hang waiting on unreleased connections. Nothing left to drain once the wait is done.
await agent?.destroy();
}

if (abortOnContainerExit && containerExited) {
return this.handleContainerExit(container);
Expand Down Expand Up @@ -165,13 +174,15 @@ export class HttpWaitStrategy extends AbstractWaitStrategy {
throw new Error(message);
}

private getAgent(): Agent | undefined {
if (this._allowInsecure) {
return new Agent({
connect: {
rejectUnauthorized: false,
},
});
private createInsecureAgent(): Agent | undefined {
if (!this._allowInsecure) {
return undefined;
}

return new Agent({
connect: {
rejectUnauthorized: false,
},
});
}
}
Loading