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
88 changes: 81 additions & 7 deletions src/gateways/api-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ async function parseJsonResponse<T>(res: Response, operation: string): Promise<T
}
}

type ResultsResponse =
paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json'];

/**
* The last results body a poll loop received and the ETag it came with, so
* the next poll can revalidate with `If-None-Match` and get an empty 304 back
* when nothing changed. Create one per poll loop; `getResultsForUpload` fills
* and clears it.
*/
export interface ResultsPollCache {
body?: ResultsResponse;
etag?: string;
}

export const ApiGateway = {
/**
* Enhances generic "fetch failed" errors with more specific diagnostic information
Expand Down Expand Up @@ -465,26 +479,86 @@ export const ApiGateway = {
}
},

/**
* Fetch the result rows for an upload, for the `dcd cloud` poll loop.
*
* Asks for `?view=summary`, which leaves out `result_files` and the rest of
* the columns the CLI never reads. An API that predates the view ignores
* the parameter and returns the full rows, a superset, so this works
* against either.
*
* With a `cache`, a repeat poll revalidates with `If-None-Match` and a 304
* hands back the cached body.
* @param baseUrl API base URL
* @param auth AuthContext carrying request headers
* @param uploadId Upload ID to fetch results for
* @param cache Optional per-poll-loop cache, filled and cleared here
* @returns The results response body
*/
async getResultsForUpload(
baseUrl: string,
auth: AuthContext,
uploadId: string,
cache?: ResultsPollCache,
) {
// TODO: merge with getUploadStatus
const url = `${baseUrl}/results/${uploadId}?view=summary`;
try {
const res = await fetch(`${baseUrl}/results/${uploadId}`, {
headers: { ...auth.headers },
});
const headers: Record<string, string> = { ...auth.headers };
if (cache?.body && cache.etag) {
headers['if-none-match'] = cache.etag;
// Per the Fetch spec, undici appends `Cache-Control: no-cache` to any
// request that carries a conditional header and no Cache-Control of
// its own, and Express's `fresh` never answers 304 to a no-cache
// request. So every poll would get a full 200. max-age=0 still asks
// for revalidation, and keeps undici from adding no-cache.
headers['cache-control'] = 'max-age=0';
}

const res = await fetch(url, { headers });

// Before the `!res.ok` check, since a 304 is not `ok`.
if (res.status === 304) {
if (cache?.body) {
return cache.body;
}

// We sent no If-None-Match, so there is nothing to reuse. Start over
// with an unconditional request on the next poll.
if (cache) {
delete cache.body;
delete cache.etag;
}

throw new Error(
'Failed to get results: API returned 304 Not Modified to an unconditional request',
);
}

if (!res.ok) {
await this.handleApiError(res, 'Failed to get results');
}

return await parseJsonResponse<
paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json']
>(res, 'Failed to get results');
const body = await parseJsonResponse<ResultsResponse>(res, 'Failed to get results');

if (cache) {
// Only pin a real result set. The API reports some errors as a 200
// with a `{ statusCode, message }` envelope, and an empty set is
// rejected by the poll loop; caching either would replay it on 304.
const etag = res.headers.get('etag');
if (etag && Array.isArray(body.results) && body.results.length > 0) {
cache.body = body;
cache.etag = etag;
} else {
delete cache.body;
delete cache.etag;
}
}

return body;
} catch (error) {
if (error instanceof TypeError && error.message === 'fetch failed') {
throw this.enhanceFetchError(error, `${baseUrl}/results/${uploadId}`);
throw this.enhanceFetchError(error, url);
}

throw error;
Expand Down
30 changes: 25 additions & 5 deletions src/services/results-polling.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as path from 'node:path';

import { ApiGateway } from '../gateways/api-gateway.js';
import { ApiGateway, type ResultsPollCache } from '../gateways/api-gateway.js';
import {
RealtimeResultsGateway,
type RealtimeResultsSubscription,
Expand Down Expand Up @@ -215,6 +215,10 @@ export class ResultsPollingService {

let sequentialPollFailures = 0;

// The last result set and its ETag, so a poll that finds nothing changed
// gets an empty 304 instead of the whole set again.
const resultsCache: ResultsPollCache = {};

const pollIntervalMs =
auth.mode === 'bearer'
? this.BEARER_POLL_INTERVAL_MS
Expand Down Expand Up @@ -324,7 +328,14 @@ export class ResultsPollingService {
renderStatus();

auth = await this.refreshPollingAuth(auth, uploadId, subscription, debug, logger);
const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
const updatedResults = await this.fetchAndLogResults(
apiUrl,
auth,
uploadId,
resultsCache,
debug,
logger,
);

const { summary } = this.calculateStatusSummary(updatedResults);
if (!json) {
Expand Down Expand Up @@ -547,6 +558,7 @@ export class ResultsPollingService {
* @param apiUrl API base URL
* @param auth AuthContext carrying request headers
* @param uploadId Upload ID to fetch results for
* @param cache The poll loop's results cache, for ETag revalidation
* @param debug Whether debug logging is enabled
* @param logger Optional logger function
* @returns Promise resolving to test results
Expand All @@ -555,23 +567,31 @@ export class ResultsPollingService {
apiUrl: string,
auth: AuthContext,
uploadId: string,
cache: ResultsPollCache,
debug: boolean,
logger?: (message: string) => void,
): Promise<TestResult[]> {
if (debug && logger) {
logger(`[DEBUG] Polling for results: ${uploadId}`);
}

const { results: updatedResults } =
await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
// A 304 hands back the cached body itself; a 200 is always a new object.
const cachedBody = cache.body;
const response = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId, cache);
const notModified = cachedBody !== undefined && response === cachedBody;
const { results: updatedResults } = response;

// An empty array would otherwise read as "all complete, all passed".
if (!updatedResults || updatedResults.length === 0) {
throw new Error('no results');
}

if (debug && logger) {
logger(`[DEBUG] Poll received ${updatedResults.length} results`);
logger(
notModified
? `[DEBUG] Poll not modified (304), reusing ${updatedResults.length} cached results`
: `[DEBUG] Poll received ${updatedResults.length} results`,
);
for (const result of updatedResults) {
logger(
`[DEBUG] Result status: ${result.test_file_name} - ${result.status}`,
Expand Down
Loading
Loading