diff --git a/integration-tests/ci-visibility/playwright-tests-dynamic/dynamic-name-test.js b/integration-tests/ci-visibility/playwright-tests-dynamic/dynamic-name-test.js index 9fc84655ba..28ac888004 100644 --- a/integration-tests/ci-visibility/playwright-tests-dynamic/dynamic-name-test.js +++ b/integration-tests/ci-visibility/playwright-tests-dynamic/dynamic-name-test.js @@ -1,41 +1,38 @@ 'use strict' -const crypto = require('crypto') const { test, expect } = require('@playwright/test') -const uuid = crypto.randomBytes(16).toString('hex') - .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5') - test.describe('dynamic name suite', () => { - test(`can do stuff at ${Date.now()}`, () => { + // Playwright loads this file during discovery and again in workers, so test names must match across processes. + test('can do stuff at 1750000000000', () => { expect(1 + 2).toBe(3) }) - test(`connects to localhost:${3000 + Math.floor(Math.random() * 60000)}`, () => { + test('connects to localhost:54321', () => { expect(2 + 3).toBe(5) }) - test(`user session ${uuid}`, () => { + test('user session 12345678-1234-1234-1234-123456789abc', () => { expect(3 + 4).toBe(7) }) - test(`created at ${new Date().toISOString()}`, () => { + test('created at 2026-08-31T12:34:56.789Z', () => { expect(4 + 5).toBe(9) }) - test(`event on ${new Date().toISOString().split('T')[0]}`, () => { + test('event on 2026-08-31', () => { expect(5 + 6).toBe(11) }) - test(`probability ${Math.random()}`, () => { + test('probability 0.1234567890', () => { expect(6 + 7).toBe(13) }) - test(`server at 127.0.0.1:${3000 + Math.floor(Math.random() * 60000)}`, () => { + test('server at 127.0.0.1:54322', () => { expect(7 + 8).toBe(15) }) - test(`bound to 0.0.0.0:${3000 + Math.floor(Math.random() * 60000)}`, () => { + test('bound to 0.0.0.0:54323', () => { expect(8 + 9).toBe(17) }) }) diff --git a/integration-tests/helpers/index.js b/integration-tests/helpers/index.js index 34b8c9ece3..f0ae69230a 100644 --- a/integration-tests/helpers/index.js +++ b/integration-tests/helpers/index.js @@ -886,9 +886,10 @@ async function curlAndAssertMessage (agent, procOrUrl, fn, timeout, expectedMess */ function getCiVisAgentlessConfig (port) { // We remove GITHUB_WORKSPACE so the repository root is not assigned to dd-trace-js. - // The outer workflow's event payload references commits that do not exist in the sandbox repository. + // The outer workflow's GitHub metadata references commits that do not exist in the sandbox repository. + // GITHUB_RUN_ID is Cucumber's primary key for GitHub detection; without it, the missing event file is ignored. // We remove MOCHA_OPTIONS so the test runner doesn't run the tests twice - const { GITHUB_EVENT_PATH, GITHUB_WORKSPACE, MOCHA_OPTIONS, ...rest } = process.env + const { GITHUB_ACTIONS, GITHUB_EVENT_PATH, GITHUB_RUN_ID, GITHUB_WORKSPACE, MOCHA_OPTIONS, ...rest } = process.env return { ...rest, DD_API_KEY: '1', @@ -905,9 +906,10 @@ function getCiVisAgentlessConfig (port) { */ function getCiVisEvpProxyConfig (port) { // We remove GITHUB_WORKSPACE so the repository root is not assigned to dd-trace-js. - // The outer workflow's event payload references commits that do not exist in the sandbox repository. + // The outer workflow's GitHub metadata references commits that do not exist in the sandbox repository. + // GITHUB_RUN_ID is Cucumber's primary key for GitHub detection; without it, the missing event file is ignored. // We remove MOCHA_OPTIONS so the test runner doesn't run the tests twice - const { GITHUB_EVENT_PATH, GITHUB_WORKSPACE, MOCHA_OPTIONS, ...rest } = process.env + const { GITHUB_ACTIONS, GITHUB_EVENT_PATH, GITHUB_RUN_ID, GITHUB_WORKSPACE, MOCHA_OPTIONS, ...rest } = process.env return { ...rest, DD_TRACE_AGENT_PORT: String(port), diff --git a/integration-tests/playwright/playwright-atr.spec.js b/integration-tests/playwright/playwright-atr.spec.js index c50ed5e1eb..b70114885a 100644 --- a/integration-tests/playwright/playwright-atr.spec.js +++ b/integration-tests/playwright/playwright-atr.spec.js @@ -15,6 +15,9 @@ const { const { createWebAppServer } = require('../ci-visibility/web-app-server') const { TEST_STATUS, + TEST_NAME, + TEST_IS_NEW, + TEST_HAS_DYNAMIC_NAME, TEST_IS_RETRY, TEST_RETRY_REASON, TEST_HAS_FAILED_ALL_RETRIES, @@ -248,6 +251,19 @@ versions.forEach((version) => { }) receiver.setKnownTests({ playwright: {} }) + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const uniqueTests = new Map(tests.map(test => [test.meta[TEST_NAME], test])) + + assert.strictEqual(uniqueTests.size, 8) + for (const test of uniqueTests.values()) { + assert.strictEqual(test.meta[TEST_IS_NEW], 'true') + assert.strictEqual(test.meta[TEST_HAS_DYNAMIC_NAME], 'true') + } + }, 30000) + const proc = run( './node_modules/.bin/playwright test -c playwright.config.js', { @@ -263,14 +279,9 @@ versions.forEach((version) => { proc.stdout?.on('data', chunk => { testOutput += chunk.toString() }) proc.stderr?.on('data', chunk => { testOutput += chunk.toString() }) - const eventsPromise = receiver - .gatherPayloadsUntilChildExit(proc, ({ url }) => url.endsWith('/api/v2/citestcycle'), () => { - assert.match(testOutput, /detected as new but their names contain dynamic data/) - }) - const [[exitCode]] = await Promise.all([once(proc, 'exit'), eventsPromise]) - // Dynamic names differ between discovery and worker processes, so Playwright cannot find these tests. - assert.strictEqual(exitCode, 1, testOutput) + assert.strictEqual(exitCode, 0, testOutput) + assert.match(testOutput, /detected as new but their names contain dynamic data/) }) }) }) diff --git a/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js b/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js index 8a900a27c2..4547217897 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +++ b/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js @@ -7,6 +7,7 @@ const CiVisibilityExporter = require('../ci-visibility-exporter') const request = require('../request') const { fetchAgentInfo } = require('../../../agent/info') const { DEBUGGER_INPUT_V1 } = require('../../../debugger/constants') +const { FINAL_FLUSH_TIMEOUT } = require('../../final-flush') // Product-specific discovery: newest advertised version, skip v3 (citestcycle), gzip if >= v4. // Shared `evp_proxy` discovery is an explicit path allowlist and does not cover this contract. @@ -47,7 +48,16 @@ class AgentProxyCiVisibilityExporter extends CiVisibilityExporter { } = config const initializationController = new AbortController() - const initializationOptions = { signal: initializationController.signal } + const initializationOptions = { + deadline: Date.now() + FINAL_FLUSH_TIMEOUT, + signal: initializationController.signal, + // Test runners await agent discovery before starting. A detached retry can let Node exit + // while that promise is still pending because promises alone do not keep the event loop alive. + keepProcessAlive: true, + // Loading a test framework can block the event loop past the payload creation-time timeout. + // The transport timeout still bounds each attempt, and the deadline bounds all retries. + timeoutFromCreation: false, + } this._initializationRequest = { controller: initializationController, options: initializationOptions, diff --git a/packages/dd-trace/src/ci-visibility/exporters/request.js b/packages/dd-trace/src/ci-visibility/exporters/request.js index ca5b13cc8a..24594c2e58 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/request.js +++ b/packages/dd-trace/src/ci-visibility/exporters/request.js @@ -180,6 +180,8 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { const timeout = options.timeout || 2000 const payloadSize = reservedPayloadSize ?? getPayloadSize(data) let retryTimer + let attemptTimer + let attemptTimerImmediate let attemptController let settled = false let lastError @@ -197,6 +199,8 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { if (settled) return settled = true clearTimeout(retryTimer) + clearTimeout(attemptTimer) + clearImmediate(attemptTimerImmediate) signal?.removeEventListener('abort', onAbort) bufferedBytes -= payloadSize callback(error, result, statusCode, headers) @@ -244,24 +248,41 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { if (!commonRequest.writable) { waitingForBackpressure = true retryTimer = setTimeout(attempt, Math.min(BACKPRESSURE_RETRY_MS, remaining), attemptIndex) - retryTimer.unref?.() + if (!options.keepProcessAlive) retryTimer.unref?.() return } waitingForBackpressure = false - const attemptDeadline = deadline const attemptOptions = { ...options, + deferTimeoutAbort: true, headers: options.headers ? { ...options.headers } : undefined, retry: false, } if (deadline !== undefined) attemptOptions.timeout = Math.max(1, Math.min(timeout, remaining)) const controller = new AbortController() + const attemptTimeout = attemptOptions.timeout || timeout + let attemptTimedOut = false + attemptController = controller attemptOptions.signal = controller.signal + if (options.timeoutFromCreation !== false) { + attemptTimer = setTimeout(() => { + // Let a response that became ready while the event loop was blocked win before aborting it. + attemptTimerImmediate = setImmediate(() => { + if (settled || attemptController !== controller) return + attemptTimedOut = true + controller.abort(createRequestTimeoutError()) + }) + if (!options.keepProcessAlive) attemptTimerImmediate.unref?.() + }, attemptTimeout) + if (!options.keepProcessAlive) attemptTimer.unref?.() + } commonRequest(data, attemptOptions, (error, result, statusCode, headers) => { + clearTimeout(attemptTimer) + clearImmediate(attemptTimerImmediate) if (attemptController === controller) attemptController = undefined if (settled) return if (!error) { @@ -269,15 +290,24 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { return } - lastError = error + const requestError = attemptTimedOut ? createRequestTimeoutError() : error + lastError = requestError const responseStatus = statusCode ?? error.status - const isRetriableError = isRetriableNetworkError(error) || isRetriableHttpStatusCode(responseStatus) - const deadlineExtended = options.deadline !== undefined && - (attemptDeadline === undefined || options.deadline > attemptDeadline) - const reachedAttemptLimit = attemptIndex >= getMaxAttempts(attemptOptions) && !deadlineExtended - if (options.retry === false || !isRetriableError || reachedAttemptLimit) { - complete(error, result, statusCode, headers) + const isUnknownNetworkError = responseStatus === undefined && error.code === undefined + const isRetriableError = + attemptTimedOut || isRetriableNetworkError(error) || isUnknownNetworkError || + isRetriableHttpStatusCode(responseStatus) + const retryUntilDeadline = options.deadline !== undefined && options.retryUntilDeadline !== false + const reachedAttemptLimit = !retryUntilDeadline && attemptIndex >= getMaxAttempts(attemptOptions) + const reachedUnknownNetworkAttemptLimit = isUnknownNetworkError && attemptIndex >= 2 + if ( + options.retry === false || + !isRetriableError || + reachedAttemptLimit || + reachedUnknownNetworkAttemptLimit + ) { + complete(requestError, result, statusCode, headers) return } @@ -288,11 +318,11 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { if (options.deadline !== undefined) { const retryRemaining = options.deadline - Date.now() if (resetDelay >= retryRemaining) { - complete(error, result, statusCode, headers) + complete(requestError, result, statusCode, headers) return } } else if (resetDelay > RATE_LIMIT_MAX_WAIT_MS) { - complete(error, result, statusCode, headers) + complete(requestError, result, statusCode, headers) return } retryDelay = resetDelay @@ -303,7 +333,7 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { if (options.deadline !== undefined && retryDelay === undefined) { const retryRemaining = options.deadline - Date.now() if (retryRemaining <= 0) { - complete(error, result, statusCode, headers) + complete(requestError, result, statusCode, headers) return } const retryAttemptTimeout = timeout < retryRemaining ? timeout : Math.ceil(retryRemaining / 2) @@ -311,7 +341,7 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { } retryTimer = setTimeout(attempt, delay, attemptIndex + 1) - retryTimer.unref?.() + if (!options.keepProcessAlive) retryTimer.unref?.() }) } diff --git a/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js b/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js index 55c230624e..1c9c066269 100644 --- a/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +++ b/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js @@ -130,6 +130,7 @@ function uploadTestScreenshot ( timeout: UPLOAD_TIMEOUT_MS, url, deadline, + retryUntilDeadline: false, signal, } diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index 77c93deed4..b306c8f5d7 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -182,6 +182,7 @@ function request (data, options, callback) { legacyStorage.run({ noop: true }, () => { let finished = false let settled = false + let timeoutImmediate const finalize = () => { if (finished) return finished = true @@ -197,6 +198,7 @@ function request (data, options, callback) { const complete = (error, result, statusCode, headers) => { if (settled) return settled = true + clearImmediate(timeoutImmediate) finalize() callback(error, result, statusCode, headers) } @@ -206,6 +208,7 @@ function request (data, options, callback) { */ const handleError = (error) => { if (settled) return + clearImmediate(timeoutImmediate) if (options.retry !== false && attemptIndex < getMaxAttempts(options) && @@ -227,7 +230,8 @@ function request (data, options, callback) { req.once('timeout', finalize) req.once('error', handleError) - req.setTimeout(timeout, () => { + const abortRequest = () => { + if (settled) return try { if (typeof req.abort === 'function') { req.abort() @@ -237,6 +241,16 @@ function request (data, options, callback) { } catch { // ignore } + } + + req.setTimeout(timeout, () => { + if (!options.deferTimeoutAbort) { + abortRequest() + return + } + + timeoutImmediate = setImmediate(abortRequest) + if (!options.keepProcessAlive) timeoutImmediate.unref?.() }) for (const buffer of dataArray) req.write(buffer) diff --git a/packages/dd-trace/test/ci-visibility/exporters/agent-proxy/agent-proxy.spec.js b/packages/dd-trace/test/ci-visibility/exporters/agent-proxy/agent-proxy.spec.js index b3e0604df7..448479adfb 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/agent-proxy/agent-proxy.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/agent-proxy/agent-proxy.spec.js @@ -97,6 +97,17 @@ describe('AgentProxyCiVisibilityExporter', () => { assert.strictEqual(scope.isDone(), true) }) + it('retries agent info initialization within the final flush timeout', () => { + const clock = sinon.useFakeTimers() + try { + const controlled = createControlledExporter() + + assert.strictEqual(controlled.getRequestOptions().deadline, Date.now() + FINAL_FLUSH_TIMEOUT) + } finally { + clock.restore() + } + }) + it('exports buffered data and flushes it when initialization finishes within the final deadline', async () => { const clock = sinon.useFakeTimers() try { @@ -108,6 +119,8 @@ describe('AgentProxyCiVisibilityExporter', () => { controlled.exporter.flush(done) const requestOptions = controlled.getRequestOptions() + assert.strictEqual(requestOptions.keepProcessAlive, true) + assert.strictEqual(requestOptions.timeoutFromCreation, false) assert.strictEqual(requestOptions.signal.aborted, false) assert.strictEqual(requestOptions.deadline, Date.now() + FINAL_FLUSH_TIMEOUT) diff --git a/packages/dd-trace/test/ci-visibility/exporters/request.spec.js b/packages/dd-trace/test/ci-visibility/exporters/request.spec.js index c3e18a17e6..f863f0dea8 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/request.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/request.spec.js @@ -38,6 +38,15 @@ describe('Test Optimization exporter request', () => { clock.restore() }) + it('lets the transport process a ready response before aborting on timeout', () => { + const done = sinon.spy() + request('payload', {}, done) + + assert.strictEqual(pendingRequests[0].options.deferTimeoutAbort, true) + pendingRequests[0].callback(null, 'ok', 200, {}) + sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) + }) + it('retries a 5xx response within the finalization deadline', () => { const done = sinon.spy() request('payload', { deadline: Date.now() + 10_000 }, done) @@ -53,11 +62,28 @@ describe('Test Optimization exporter request', () => { sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) }) - it('keeps the ordinary attempt cap during finalization', () => { + it('keeps retrying retriable responses while the finalization deadline has capacity', () => { const done = sinon.spy() request('payload', { deadline: Date.now() + 30_000 }, done) const error = Object.assign(new Error('unavailable'), { status: 503 }) + pendingRequests[0].callback(error, null, 503, {}) + clock.tick(6000) + pendingRequests[1].callback(error, null, 503, {}) + clock.tick(6000) + pendingRequests[2].callback(error, null, 503, {}) + clock.tick(6000) + pendingRequests[3].callback(null, 'ok', 200, {}) + + assert.strictEqual(pendingRequests.length, 4) + sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) + }) + + it('keeps the ordinary attempt cap when deadline retries are disabled', () => { + const done = sinon.spy() + request('payload', { deadline: Date.now() + 30_000, retryUntilDeadline: false }, done) + const error = Object.assign(new Error('unavailable'), { status: 503 }) + pendingRequests[0].callback(error, null, 503, {}) clock.tick(6000) pendingRequests[1].callback(error, null, 503, {}) @@ -253,6 +279,147 @@ describe('Test Optimization exporter request', () => { assert.strictEqual(pendingRequests.length, 2) }) + for (const [description, options, expectedHasRef] of [ + ['detaches background retry timers by default', {}, false], + ['keeps an owned retry alive', { keepProcessAlive: true }, true], + ]) { + it(description, () => { + const setTimeoutSpy = sinon.spy(global, 'setTimeout') + try { + request('payload', options, sinon.spy()) + + const error = Object.assign(new Error('reset'), { code: 'ECONNRESET' }) + pendingRequests[0].callback(error) + + const retryTimer = setTimeoutSpy.lastCall.returnValue + assert.strictEqual(retryTimer.hasRef(), expectedHasRef) + } finally { + setTimeoutSpy.restore() + } + }) + } + + it('retries an unknown network error once', () => { + const done = sinon.spy() + request('payload', {}, done) + + pendingRequests[0].callback(new Error('network failure')) + clock.tick(6000) + + assert.strictEqual(pendingRequests.length, 2) + pendingRequests[1].callback(null, 'ok', 200, {}) + sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) + }) + + it('does not extend the unknown network retry budget during finalization', () => { + const done = sinon.spy() + const options = { deadline: Date.now() + 1000, timeout: 2000 } + request('payload', options, done) + const firstError = new Error('first network failure') + const secondError = new Error('second network failure') + + pendingRequests[0].callback(firstError) + clock.tick(500) + options.deadline = Date.now() + 2000 + pendingRequests[1].callback(secondError) + clock.tick(1000) + + assert.strictEqual(pendingRequests.length, 2) + sinon.assert.calledOnceWithExactly(done, secondError, undefined, undefined, undefined) + }) + + it('does not retry a coded non-transient network error', () => { + const done = sinon.spy() + request('payload', {}, done) + const error = Object.assign(new Error('unknown host'), { code: 'ENOTFOUND' }) + + pendingRequests[0].callback(error) + clock.tick(6000) + + assert.strictEqual(pendingRequests.length, 1) + sinon.assert.calledOnceWithExactly(done, error, undefined, undefined, undefined) + }) + + it('times out a transport attempt from creation and retries it', () => { + const done = sinon.spy() + request('payload', { deadline: Date.now() + 30_000, timeout: 1000 }, done) + + const firstRequest = pendingRequests[0] + firstRequest.options.signal.addEventListener('abort', () => { + const error = Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }) + firstRequest.callback(error) + }) + + clock.tick(999) + assert.strictEqual(firstRequest.options.signal.aborted, false) + clock.tick(1) + clock.next() + assert.strictEqual(firstRequest.options.signal.aborted, true) + sinon.assert.notCalled(done) + + clock.tick(5999) + assert.strictEqual(pendingRequests.length, 1) + clock.tick(1) + assert.strictEqual(pendingRequests.length, 2) + + pendingRequests[1].callback(null, 'ok', 200, {}) + sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) + }) + + it('lets a ready transport response win at the creation-time timeout boundary', () => { + let abortImmediate + const immediateHandle = { unref: sinon.spy() } + const setImmediateStub = sinon.stub(global, 'setImmediate').callsFake(callback => { + abortImmediate = callback + return immediateHandle + }) + const clearImmediateStub = sinon.stub(global, 'clearImmediate') + const done = sinon.spy() + try { + request('payload', { timeout: 1000 }, done) + const pendingRequest = pendingRequests[0] + + clock.tick(1000) + assert.strictEqual(pendingRequest.options.signal.aborted, false) + pendingRequest.callback(null, 'ok', 200, {}) + abortImmediate() + + assert.strictEqual(pendingRequest.options.signal.aborted, false) + sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) + } finally { + clearImmediateStub.restore() + setImmediateStub.restore() + } + }) + + it('can leave connection timing to the transport for an owned request', () => { + const done = sinon.spy() + request('payload', { timeout: 1000, timeoutFromCreation: false }, done) + + clock.tick(1000) + + assert.strictEqual(pendingRequests[0].options.signal.aborted, false) + assert.strictEqual(pendingRequests[0].options.timeout, 1000) + sinon.assert.notCalled(done) + }) + + it('reports a transport attempt timeout when retries are disabled', () => { + const done = sinon.spy() + request('payload', { retry: false, timeout: 1000 }, done) + + const pendingRequest = pendingRequests[0] + pendingRequest.options.signal.addEventListener('abort', () => { + const error = Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }) + pendingRequest.callback(error) + }) + + clock.tick(1000) + clock.next() + + sinon.assert.calledOnce(done) + assert.strictEqual(done.firstCall.args[0].code, 'ERR_DD_TEST_OPTIMIZATION_REQUEST_TIMEOUT') + }) + it('aborts a scheduled retry and completes once', () => { const controller = new AbortController() const done = sinon.spy() diff --git a/packages/dd-trace/test/ci-visibility/requests/upload-test-screenshot.spec.js b/packages/dd-trace/test/ci-visibility/requests/upload-test-screenshot.spec.js index 46b7906c47..9594080cc9 100644 --- a/packages/dd-trace/test/ci-visibility/requests/upload-test-screenshot.spec.js +++ b/packages/dd-trace/test/ci-visibility/requests/upload-test-screenshot.spec.js @@ -38,9 +38,9 @@ describe('ci-visibility/requests/upload-test-screenshot', () => { ) assert.ok(requestStub.calledOnce) - const { path, headers, deadline, signal } = requestStub.getCall(0).args[1] + const { path, headers, deadline, retryUntilDeadline, signal } = requestStub.getCall(0).args[1] const query = new URL(path, 'http://localhost:8126').searchParams - return { path, headers, query, deadline, signal } + return { path, headers, query, deadline, retryUntilDeadline, signal } } before(() => { @@ -87,6 +87,7 @@ describe('ci-visibility/requests/upload-test-screenshot', () => { const requestOptions = uploadForFile('screenshot.png', { deadline, signal: abortController.signal }) assert.strictEqual(requestOptions.deadline, deadline) + assert.strictEqual(requestOptions.retryUntilDeadline, false) assert.strictEqual(requestOptions.signal, abortController.signal) }) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index 0c6d5d3b90..9a9b5b84b9 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -314,6 +314,64 @@ describe('request', function () { assert.strictEqual(callbacks, 1) }) + it('lets callers defer a timeout abort until a ready response is processed', async () => { + const response = new EventEmitter() + response.headers = {} + response.statusCode = 200 + response.setTimeout = sinon.spy() + + let respond + let onTimeout + const requestMessage = new EventEmitter() + requestMessage.abort = sinon.spy() + requestMessage.setTimeout = (timeout, callback) => { + assert.strictEqual(timeout, 2000) + onTimeout = callback + } + requestMessage.write = sinon.spy() + requestMessage.end = sinon.spy() + + /** + * @param {object} options + * @param {(response: EventEmitter) => void} onResponse + */ + const createRequest = (options, onResponse) => { + assert.strictEqual(options.method, 'GET') + assert.strictEqual(options.deferTimeoutAbort, true) + respond = onResponse + return requestMessage + } + const deferredTimeoutRequest = proxyquire('../../../src/exporters/common/request', { + '../../../../datadog-core': { + storage: () => ({ run: runInNoopContext }), + }, + http: { ...http, request: createRequest }, + './docker': docker, + '../../log': log, + './retry': { + ...require('../../../src/exporters/common/retry'), + ...retryStubs, + }, + }) + + const completed = new Promise(resolve => { + deferredTimeoutRequest('', { method: 'GET', retry: false, deferTimeoutAbort: true }, (...args) => resolve(args)) + }) + + onTimeout() + respond(response) + response.emit('data', Buffer.from('OK')) + response.emit('end') + + const [error, result, statusCode] = await completed + await new Promise(resolve => setImmediate(resolve)) + + assert.strictEqual(error, null) + assert.strictEqual(result, 'OK') + assert.strictEqual(statusCode, 200) + sinon.assert.notCalled(requestMessage.abort) + }) + it('should handle an http error', done => { nock('http://localhost:8080') .put('/path')