diff --git a/packages/platform-server/__tests__/infra/httpDockerRunner.client.test.ts b/packages/platform-server/__tests__/infra/httpDockerRunner.client.test.ts index 31189b450..a6d0fa436 100644 --- a/packages/platform-server/__tests__/infra/httpDockerRunner.client.test.ts +++ b/packages/platform-server/__tests__/infra/httpDockerRunner.client.test.ts @@ -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'; @@ -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> = []; + const fetchImpl = vi.fn(async (_url: URL | string, init?: { headers?: Record }) => { + 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); + }); +}); diff --git a/packages/platform-server/src/infra/container/httpDockerRunner.client.ts b/packages/platform-server/src/infra/container/httpDockerRunner.client.ts index 6ff2058d0..d7a4223e6 100644 --- a/packages/platform-server/src/infra/container/httpDockerRunner.client.ts +++ b/packages/platform-server/src/infra/container/httpDockerRunner.client.ts @@ -50,6 +50,7 @@ type RequestOptions = { body?: unknown; expectedStatus?: number; timeoutMs?: number; + maxRetries?: number; }; const RETRYABLE_STATUS = new Set([502, 503, 504]); @@ -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 => { 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, @@ -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); @@ -304,7 +306,7 @@ export class HttpDockerRunnerClient implements DockerClient { async execContainer(containerId: string, command: string[] | string, options?: ExecOptions): Promise { const body: ExecRunRequest = { containerId, command, options }; const timeoutMs = this.resolveExecRequestTimeout(options); - return this.send({ method: 'POST', path: '/v1/exec/run', body, timeoutMs }); + return this.send({ method: 'POST', path: '/v1/exec/run', body, timeoutMs, maxRetries: 0 }); } async openInteractiveExec(