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
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Response } from 'undici';

import { DockerRunnerRequestError, HttpDockerRunnerClient, EXEC_REQUEST_TIMEOUT_SLACK_MS } from '../../src/infra/container/httpDockerRunner.client';

Expand Down Expand Up @@ -116,3 +117,52 @@ describe('HttpDockerRunnerClient exec websocket errors', () => {
expect(error.message).toContain('No such container');
});
});

describe('HttpDockerRunnerClient retries and exec policy', () => {
const baseConfig = {
baseUrl: 'http://runner.internal:7071',
sharedSecret: 'secret',
};

afterEach(() => {
vi.restoreAllMocks();
});

it('regenerates auth headers for every retry attempt', async () => {
const captured: Array<Record<string, string>> = [];
const fetchImpl = vi.fn(async (_url: URL | string, init?: { headers?: Record<string, string> }) => {
captured.push({ ...(init?.headers ?? {}) });
if (captured.length === 1) {
throw new Error('network glitch');
}
return {
status: 204,
ok: true,
json: async () => undefined,
} as Response;
});

const client = new HttpDockerRunnerClient({ ...baseConfig, fetchImpl: fetchImpl as unknown as typeof fetch });

await client.ensureImage('alpine:3');

expect(captured).toHaveLength(2);
expect(captured[0]['x-dr-nonce']).toBeDefined();
expect(captured[1]['x-dr-nonce']).toBeDefined();
expect(captured[0]['x-dr-nonce']).not.toBe(captured[1]['x-dr-nonce']);
expect(captured[0]['x-dr-timestamp']).toBeDefined();
expect(captured[1]['x-dr-timestamp']).toBeDefined();
expect(captured[0]['x-dr-timestamp']).not.toBe(captured[1]['x-dr-timestamp']);
});

it('does not retry exec run requests by default', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('runner offline');
});

const client = new HttpDockerRunnerClient({ ...baseConfig, fetchImpl: fetchImpl as unknown as typeof fetch });

await expect(client.execContainer('cid', ['echo', 'hello'])).rejects.toBeInstanceOf(DockerRunnerRequestError);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type RequestOptions = {
body?: unknown;
expectedStatus?: number;
timeoutMs?: number;
maxRetries?: number;
};

const RETRYABLE_STATUS = new Set([502, 503, 504]);
Expand Down Expand Up @@ -219,18 +220,19 @@ export class HttpDockerRunnerClient implements DockerClient {
.map(([k, v]) => [k, String(v ?? '')]),
).toString()}`
: options.path;
const headers = buildAuthHeaders({
method: options.method,
path: pathWithQuery,
body: bodyString,
secret: this.sharedSecret,
});
const maxRetries = options.maxRetries ?? this.maxRetries;

type ExecuteResult = { success: true; data: T } | { success: false; error: DockerRunnerRequestError };

const execute = async (): Promise<ExecuteResult> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? this.requestTimeoutMs);
const headers = buildAuthHeaders({
method: options.method,
path: pathWithQuery,
body: bodyString,
secret: this.sharedSecret,
});
try {
const response = await this.fetchImpl(this.buildUrl(options.path, options.query), {
method: options.method,
Expand Down Expand Up @@ -265,12 +267,12 @@ export class HttpDockerRunnerClient implements DockerClient {
};

let attempt = 0;
while (attempt <= this.maxRetries) {
while (attempt <= maxRetries) {
const result = await execute();
if (result.success) return result.data;
const { error } = result;
attempt += 1;
if (!error.retryable || attempt > this.maxRetries) {
if (!error.retryable || attempt > maxRetries) {
throw error;
}
const backoff = 200 * Math.pow(2, attempt - 1);
Expand Down Expand Up @@ -304,7 +306,7 @@ export class HttpDockerRunnerClient implements DockerClient {
async execContainer(containerId: string, command: string[] | string, options?: ExecOptions): Promise<ExecResult> {
const body: ExecRunRequest = { containerId, command, options };
const timeoutMs = this.resolveExecRequestTimeout(options);
return this.send<ExecRunResponse>({ method: 'POST', path: '/v1/exec/run', body, timeoutMs });
return this.send<ExecRunResponse>({ method: 'POST', path: '/v1/exec/run', body, timeoutMs, maxRetries: 0 });
}

async openInteractiveExec(
Expand Down