@devicecloud.dev/dcd 5.8.0-beta.2 → 5.8.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.js +38 -22
- package/dist/gateways/api-gateway.d.ts +9 -0
- package/dist/gateways/api-gateway.js +22 -2
- package/dist/mcp/tools/run-cloud-test.js +32 -17
- package/dist/services/device-validation.service.js +18 -4
- package/dist/services/flow-submit.service.d.ts +71 -0
- package/dist/services/flow-submit.service.js +117 -0
- package/dist/services/telemetry.service.d.ts +9 -0
- package/dist/services/telemetry.service.js +17 -0
- package/package.json +1 -1
package/dist/commands/cloud.js
CHANGED
|
@@ -3,10 +3,11 @@ import { defineCommand } from 'citty';
|
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { flags as allFlags } from '../constants.js';
|
|
6
|
-
import {
|
|
6
|
+
import { ApiGateway } from '../gateways/api-gateway.js';
|
|
7
7
|
import { uploadBinary, uploadFlowZip, verifyAppZip, writeJSONFile, } from '../methods.js';
|
|
8
8
|
import { DeviceValidationService } from '../services/device-validation.service.js';
|
|
9
9
|
import { plan } from '../services/execution-plan.service.js';
|
|
10
|
+
import { isSubmitEndpointMissing, submitWithRetry, } from '../services/flow-submit.service.js';
|
|
10
11
|
import { buildTestMetadataMap, computeCommonRoot, } from '../services/flow-paths.js';
|
|
11
12
|
import { MoropoService } from '../services/moropo.service.js';
|
|
12
13
|
import { ReportDownloadService } from '../services/report-download.service.js';
|
|
@@ -15,7 +16,7 @@ import { telemetry } from '../services/telemetry.service.js';
|
|
|
15
16
|
import { TestSubmissionService } from '../services/test-submission.service.js';
|
|
16
17
|
import { VersionService } from '../services/version.service.js';
|
|
17
18
|
import { isIosMatrixConfig } from '../types/domain/device.types.js';
|
|
18
|
-
import { resolveAuth } from '../utils/auth.js';
|
|
19
|
+
import { refreshAuth, resolveAuth } from '../utils/auth.js';
|
|
19
20
|
import { assertMatrixSupported, matrixIsIos, parseDeviceMatrix, } from '../utils/device-matrix.js';
|
|
20
21
|
import { checkKekForEncryption, resolveEncryption } from '../utils/envelope.js';
|
|
21
22
|
import { detectCiContext, isCI } from '../utils/ci.js';
|
|
@@ -656,31 +657,46 @@ export const cloudCommand = defineCommand({
|
|
|
656
657
|
// 404s (route undefined), some proxies 405 (path/method not allowed); in
|
|
657
658
|
// either case fall back to the legacy multipart POST /uploads/flow, which
|
|
658
659
|
// is byte-identical bar the storage reference.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
660
|
+
const submitMultipart = (status) => {
|
|
661
|
+
if (debug) {
|
|
662
|
+
out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${status}); falling back to multipart ${apiUrl}/uploads/flow`);
|
|
663
|
+
}
|
|
664
|
+
const testFormData = testSubmissionService.buildFormData(fields, buffer);
|
|
665
|
+
return ApiGateway.uploadFlow(apiUrl, auth, testFormData);
|
|
666
|
+
};
|
|
667
|
+
const submitFlows = async () => {
|
|
668
|
+
let storageRef;
|
|
669
|
+
try {
|
|
670
|
+
storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug });
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
if (!isSubmitEndpointMissing(error))
|
|
674
|
+
throw error;
|
|
675
|
+
return submitMultipart(error.status);
|
|
676
|
+
}
|
|
662
677
|
if (debug) {
|
|
663
678
|
out(`[DEBUG] Flow zip uploaded (id=${storageRef.id}, supabase=${storageRef.supabaseSuccess}, backblaze=${storageRef.backblazeSuccess})`);
|
|
664
679
|
out(`[DEBUG] Submitting flow test to ${apiUrl}/uploads/submitFlowTest`);
|
|
665
680
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
681
|
+
// The same body on every attempt: its upload id is what lets the API
|
|
682
|
+
// answer a repeat with the run it already created (dcd#1617 follow-up).
|
|
683
|
+
const submitBody = { ...fields, ...storageRef };
|
|
684
|
+
// The retries can outlast a `dcd login` access token, so each attempt
|
|
685
|
+
// re-checks it, as the poll loop does.
|
|
686
|
+
let submitAuth = auth;
|
|
687
|
+
const submitted = await submitWithRetry({
|
|
688
|
+
fallback: (error) => submitMultipart(error.status),
|
|
689
|
+
// Recorded before each attempt, so `command failed` carries it too.
|
|
690
|
+
onAttempt: (attempt) => telemetry.recordSubmitAttempts(attempt),
|
|
691
|
+
onRetry: ({ attempt, attempts, delayMs, reason }) => warnOut(`Could not confirm the test submission (${reason}); sending it again in ${delayMs / 1000}s (attempt ${attempt}/${attempts}). It will not run twice.`),
|
|
692
|
+
async submit() {
|
|
693
|
+
submitAuth = await refreshAuth(submitAuth);
|
|
694
|
+
return ApiGateway.submitFlowTest(apiUrl, submitAuth, submitBody);
|
|
695
|
+
},
|
|
669
696
|
});
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
(error.status === 404 || error.status === 405)) {
|
|
674
|
-
if (debug) {
|
|
675
|
-
out(`[DEBUG] Client-direct flow upload unavailable (HTTP ${error.status}); falling back to multipart ${apiUrl}/uploads/flow`);
|
|
676
|
-
}
|
|
677
|
-
const testFormData = testSubmissionService.buildFormData(fields, buffer);
|
|
678
|
-
response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
|
|
679
|
-
}
|
|
680
|
-
else {
|
|
681
|
-
throw error;
|
|
682
|
-
}
|
|
683
|
-
}
|
|
697
|
+
return submitted.response;
|
|
698
|
+
};
|
|
699
|
+
const response = await submitFlows();
|
|
684
700
|
const { message, results } = response;
|
|
685
701
|
if (debug) {
|
|
686
702
|
out(`[DEBUG] Flow submission response received`);
|
|
@@ -189,6 +189,15 @@ export declare const ApiGateway: {
|
|
|
189
189
|
message?: string;
|
|
190
190
|
results?: components["schemas"]["IDBResult"][];
|
|
191
191
|
}>;
|
|
192
|
+
/**
|
|
193
|
+
* How long one submit attempt may take (dcd#1617 follow-up). A submit
|
|
194
|
+
* normally answers in a second or two; one held by a Supabase stall is
|
|
195
|
+
* abandoned and re-sent by `submitWithRetry`, which is safe because the API
|
|
196
|
+
* answers a repeat with the run it already created. Well under the 300s at
|
|
197
|
+
* which Railway's edge would cut it with a 502. Settable so tests can
|
|
198
|
+
* shorten it.
|
|
199
|
+
*/
|
|
200
|
+
submitAttemptTimeoutMs: number;
|
|
192
201
|
/**
|
|
193
202
|
* Dry-run cost + cell-count estimate for a (possibly device-matrix)
|
|
194
203
|
* submission. Runs the same resolve → validate → fan-out → price core as the
|
|
@@ -51,8 +51,13 @@ export const ApiGateway = {
|
|
|
51
51
|
message += ` • Verify API URL is correct: ${origin}\n`;
|
|
52
52
|
message += ` • Check for proxy/VPN interference\n`;
|
|
53
53
|
message += ` • Try again in a few moments if server is temporarily down\n\n`;
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// undici puts the real reason on `cause` (UND_ERR_HEADERS_TIMEOUT,
|
|
55
|
+
// ECONNRESET, ENOTFOUND, …); without it a timeout, a reset and a DNS
|
|
56
|
+
// failure all read as the same "fetch failed".
|
|
57
|
+
const cause = error.cause;
|
|
58
|
+
const code = cause?.code;
|
|
59
|
+
message += `Original error: ${error.message}${typeof code === 'string' ? ` (${code})` : ''}`;
|
|
60
|
+
const enhancedError = new Error(message, { cause: cause ?? error });
|
|
56
61
|
enhancedError.name = 'NetworkError';
|
|
57
62
|
enhancedError.stack = error.stack;
|
|
58
63
|
return enhancedError;
|
|
@@ -545,6 +550,7 @@ export const ApiGateway = {
|
|
|
545
550
|
...auth.headers,
|
|
546
551
|
},
|
|
547
552
|
method: 'POST',
|
|
553
|
+
signal: AbortSignal.timeout(this.submitAttemptTimeoutMs),
|
|
548
554
|
});
|
|
549
555
|
if (!res.ok) {
|
|
550
556
|
await this.handleApiError(res, 'Failed to submit test flows');
|
|
@@ -556,9 +562,23 @@ export const ApiGateway = {
|
|
|
556
562
|
if (error instanceof TypeError && error.message === 'fetch failed') {
|
|
557
563
|
throw this.enhanceFetchError(error, `${baseUrl}/uploads/submitFlowTest`);
|
|
558
564
|
}
|
|
565
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
566
|
+
const timeout = new Error(`Failed to submit test flows: no response from the API within ${this.submitAttemptTimeoutMs / 1000}s`, { cause: error });
|
|
567
|
+
timeout.name = 'SubmitTimeoutError';
|
|
568
|
+
throw timeout;
|
|
569
|
+
}
|
|
559
570
|
throw error;
|
|
560
571
|
}
|
|
561
572
|
},
|
|
573
|
+
/**
|
|
574
|
+
* How long one submit attempt may take (dcd#1617 follow-up). A submit
|
|
575
|
+
* normally answers in a second or two; one held by a Supabase stall is
|
|
576
|
+
* abandoned and re-sent by `submitWithRetry`, which is safe because the API
|
|
577
|
+
* answers a repeat with the run it already created. Well under the 300s at
|
|
578
|
+
* which Railway's edge would cut it with a 502. Settable so tests can
|
|
579
|
+
* shorten it.
|
|
580
|
+
*/
|
|
581
|
+
submitAttemptTimeoutMs: 90_000,
|
|
562
582
|
/**
|
|
563
583
|
* Dry-run cost + cell-count estimate for a (possibly device-matrix)
|
|
564
584
|
* submission. Runs the same resolve → validate → fan-out → price core as the
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { ApiGateway } from '../../gateways/api-gateway.js';
|
|
4
4
|
import { plan } from '../../services/execution-plan.service.js';
|
|
5
|
+
import { isSubmitEndpointMissing, submitWithRetry, } from '../../services/flow-submit.service.js';
|
|
5
6
|
import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths.js';
|
|
6
7
|
import { DeviceValidationService } from '../../services/device-validation.service.js';
|
|
7
8
|
import { platformFromAppFile } from '../../services/notices.service.js';
|
|
@@ -224,24 +225,38 @@ export function registerRunCloudTest(server) {
|
|
|
224
225
|
// referencing it. Older API deployments lack these endpoints (404/405);
|
|
225
226
|
// fall back to the legacy multipart POST /uploads/flow. Mirrors
|
|
226
227
|
// `dcd cloud`.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
...storageRef,
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
catch (error) {
|
|
236
|
-
if (error instanceof ApiError &&
|
|
237
|
-
(error.status === 404 || error.status === 405)) {
|
|
238
|
-
const testFormData = testSubmissionService.buildFormData(fields, buffer);
|
|
239
|
-
response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
|
|
228
|
+
const submitMultipart = () => ApiGateway.uploadFlow(apiUrl, auth, testSubmissionService.buildFormData(fields, buffer));
|
|
229
|
+
const submitFlows = async () => {
|
|
230
|
+
let storageRef;
|
|
231
|
+
try {
|
|
232
|
+
storageRef = await uploadFlowZip({ apiUrl, auth, buffer });
|
|
240
233
|
}
|
|
241
|
-
|
|
242
|
-
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (!isSubmitEndpointMissing(error))
|
|
236
|
+
throw error;
|
|
237
|
+
return submitMultipart();
|
|
243
238
|
}
|
|
244
|
-
|
|
239
|
+
// Re-sent while its outcome is unknown, always with the same body;
|
|
240
|
+
// see submitWithRetry (dcd#1617 follow-up).
|
|
241
|
+
const submitBody = { ...fields, ...storageRef };
|
|
242
|
+
// The retries can outlast a `dcd login` access token, so each
|
|
243
|
+
// attempt re-checks it, as the wait loop below does.
|
|
244
|
+
let submitAuth = auth;
|
|
245
|
+
const submitted = await submitWithRetry({
|
|
246
|
+
fallback: submitMultipart,
|
|
247
|
+
// Recorded before each attempt, so a failed tool call carries it.
|
|
248
|
+
onAttempt: (attempt) => {
|
|
249
|
+
telemetryExtra.submitAttempts = attempt;
|
|
250
|
+
},
|
|
251
|
+
onRetry: ({ attempt, attempts, delayMs, reason }) => logStderr(`Could not confirm the test submission (${reason}); sending it again in ${delayMs / 1000}s (attempt ${attempt}/${attempts}). It will not run twice.`),
|
|
252
|
+
async submit() {
|
|
253
|
+
submitAuth = await refreshAuth(submitAuth);
|
|
254
|
+
return ApiGateway.submitFlowTest(apiUrl, submitAuth, submitBody);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
return submitted.response;
|
|
258
|
+
};
|
|
259
|
+
const response = await submitFlows();
|
|
245
260
|
const { message, results } = response;
|
|
246
261
|
if (!results?.length) {
|
|
247
262
|
throw new Error(`No tests were created: ${message}`);
|
|
@@ -47,6 +47,20 @@ export class DeviceValidationService {
|
|
|
47
47
|
? compatibilityData.androidPlay
|
|
48
48
|
: compatibilityData.android;
|
|
49
49
|
const argNames = options.argNames ?? CLI_ARG_NAMES;
|
|
50
|
+
// With Google Play the API does not give an omitted half the global
|
|
51
|
+
// default (no longer a Play cell): it picks one that makes the pair a Play
|
|
52
|
+
// cell, and only rejects when none exists (resolveAndroidTarget in
|
|
53
|
+
// compatibility.service.ts). Fill it the same way so a pair the API will
|
|
54
|
+
// run is not refused here.
|
|
55
|
+
let device = androidDevice;
|
|
56
|
+
let apiLevel = androidApiLevel;
|
|
57
|
+
if (googlePlay && device && !apiLevel) {
|
|
58
|
+
apiLevel = versionsFor(lookup, device)[0];
|
|
59
|
+
}
|
|
60
|
+
else if (googlePlay && apiLevel && !device) {
|
|
61
|
+
const level = apiLevel;
|
|
62
|
+
device = offeredDevices(lookup).find((d) => versionsFor(lookup, d).includes(level));
|
|
63
|
+
}
|
|
50
64
|
this.validateDevice({
|
|
51
65
|
debugLines: (deviceID, version, supportedVersions) => [
|
|
52
66
|
`[DEBUG] Android device: ${deviceID}`,
|
|
@@ -55,11 +69,11 @@ export class DeviceValidationService {
|
|
|
55
69
|
`[DEBUG] Supported Android versions: ${supportedVersions.join(', ')}`,
|
|
56
70
|
],
|
|
57
71
|
// Mirrors the API, which applies these same global defaults to a
|
|
58
|
-
// request that omits either half (
|
|
72
|
+
// request that omits either half (execution-planner.service.ts)
|
|
59
73
|
// and then rejects an incompatible pair.
|
|
60
74
|
defaultDevice: 'pixel-7',
|
|
61
|
-
defaultVersion: '
|
|
62
|
-
device
|
|
75
|
+
defaultVersion: '36',
|
|
76
|
+
device,
|
|
63
77
|
deviceArg: argNames.androidDevice,
|
|
64
78
|
deviceKind: googlePlay ? 'Google Play' : 'Android',
|
|
65
79
|
formatVersion: (version) => `API level ${version}`,
|
|
@@ -72,7 +86,7 @@ export class DeviceValidationService {
|
|
|
72
86
|
? `Android device "${deviceID}" is not available with Google Play. Google Play devices: ${offeredDevices(lookup).join(', ')}. See ${DEVICES_DOCS_URL}`
|
|
73
87
|
: undefined,
|
|
74
88
|
unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} ${googlePlay ? '(Play Store) ' : ''}only supports these Android API levels: ${supportedVersions.join(', ')}`,
|
|
75
|
-
version:
|
|
89
|
+
version: apiLevel,
|
|
76
90
|
versionArg: argNames.androidApiLevel,
|
|
77
91
|
versionName: 'Android API level',
|
|
78
92
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ApiError } from '../gateways/api-gateway.js';
|
|
2
|
+
/**
|
|
3
|
+
* Submitting a flow test when the response may be lost (dcd#1617 follow-up).
|
|
4
|
+
*
|
|
5
|
+
* Supabase's ingress sometimes holds a request for minutes before answering,
|
|
6
|
+
* and Railway's edge cuts a request that is silent for 300s with a 502. Either
|
|
7
|
+
* way the CLI cannot tell whether its run was created. The API makes that safe
|
|
8
|
+
* to find out: it answers a repeat of the same submission (same body, same
|
|
9
|
+
* upload id from getFlowUploadUrl) with the run it already created, or a 409
|
|
10
|
+
* while that run is still being created. It never creates a second run or a
|
|
11
|
+
* second charge. So a submit whose outcome is unknown is simply sent again.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Waits before each re-send: 2s, then up to 30s apart, about three minutes in
|
|
15
|
+
* all. That is long enough for a write held in a stall to land, after which the
|
|
16
|
+
* repeat is answered with the run.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SUBMIT_RETRY_DELAYS_MS: readonly number[];
|
|
19
|
+
/**
|
|
20
|
+
* No attempt starts more than this long after the first. Each attempt can take
|
|
21
|
+
* up to its 90s timeout, so without a cap every attempt timing out would hold
|
|
22
|
+
* the command for about 16 minutes; with it, the worst case is about 6.5.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SUBMIT_RETRY_BUDGET_MS: number;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a failed attempt is worth sending again. Anything that is not an API
|
|
27
|
+
* answer is a transport failure: a network error, the attempt timeout, or a
|
|
28
|
+
* body that died mid-read after a 2xx. In every case the run may already exist,
|
|
29
|
+
* which a repeat finds out. The npm build throws undici's errors and the bun
|
|
30
|
+
* binaries their own; neither is an ApiError, so both retry.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isRetryableSubmitError(error: unknown): boolean;
|
|
33
|
+
/** A one-line reason for the retry notice. Network errors carry a long help text. */
|
|
34
|
+
export declare function describeSubmitError(error: unknown): string;
|
|
35
|
+
/** The API predates the client-direct submit: a real API 404s, some proxies 405. */
|
|
36
|
+
export declare function isSubmitEndpointMissing(error: unknown): boolean;
|
|
37
|
+
export interface SubmitRetryNotice {
|
|
38
|
+
/** The attempt about to be sent, counting from 1. */
|
|
39
|
+
attempt: number;
|
|
40
|
+
attempts: number;
|
|
41
|
+
delayMs: number;
|
|
42
|
+
reason: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Send a submission, sending it again while its outcome is unknown.
|
|
46
|
+
*
|
|
47
|
+
* `fallback` is the legacy multipart submit for an API that predates this
|
|
48
|
+
* endpoint. It is used only when the *first* attempt says the endpoint is
|
|
49
|
+
* missing. A 404 on a later attempt comes from an API that took the earlier
|
|
50
|
+
* attempt, so a multipart submit then would create a second run.
|
|
51
|
+
*
|
|
52
|
+
* Resolves with the response and how many attempts it took. Throws the last
|
|
53
|
+
* error when it is not retryable or the attempts run out.
|
|
54
|
+
*/
|
|
55
|
+
export declare function submitWithRetry<T>(opts: {
|
|
56
|
+
budgetMs?: number;
|
|
57
|
+
delaysMs?: readonly number[];
|
|
58
|
+
fallback: (error: ApiError) => Promise<T>;
|
|
59
|
+
now?: () => number;
|
|
60
|
+
/**
|
|
61
|
+
* Called before each attempt with its number, counting from 1, so a caller
|
|
62
|
+
* can record how many were made whether the submit succeeds or not.
|
|
63
|
+
*/
|
|
64
|
+
onAttempt?: (attempt: number) => void;
|
|
65
|
+
onRetry?: (notice: SubmitRetryNotice) => void;
|
|
66
|
+
sleep?: (ms: number) => Promise<void>;
|
|
67
|
+
submit: () => Promise<T>;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
attempts: number;
|
|
70
|
+
response: T;
|
|
71
|
+
}>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ApiError } from '../gateways/api-gateway.js';
|
|
2
|
+
import { isDefinitiveAuthFailure } from '../utils/auth.js';
|
|
3
|
+
/**
|
|
4
|
+
* Submitting a flow test when the response may be lost (dcd#1617 follow-up).
|
|
5
|
+
*
|
|
6
|
+
* Supabase's ingress sometimes holds a request for minutes before answering,
|
|
7
|
+
* and Railway's edge cuts a request that is silent for 300s with a 502. Either
|
|
8
|
+
* way the CLI cannot tell whether its run was created. The API makes that safe
|
|
9
|
+
* to find out: it answers a repeat of the same submission (same body, same
|
|
10
|
+
* upload id from getFlowUploadUrl) with the run it already created, or a 409
|
|
11
|
+
* while that run is still being created. It never creates a second run or a
|
|
12
|
+
* second charge. So a submit whose outcome is unknown is simply sent again.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Waits before each re-send: 2s, then up to 30s apart, about three minutes in
|
|
16
|
+
* all. That is long enough for a write held in a stall to land, after which the
|
|
17
|
+
* repeat is answered with the run.
|
|
18
|
+
*/
|
|
19
|
+
export const SUBMIT_RETRY_DELAYS_MS = [
|
|
20
|
+
2000, 5000, 10_000, 20_000, 30_000, 30_000, 30_000, 30_000,
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* No attempt starts more than this long after the first. Each attempt can take
|
|
24
|
+
* up to its 90s timeout, so without a cap every attempt timing out would hold
|
|
25
|
+
* the command for about 16 minutes; with it, the worst case is about 6.5.
|
|
26
|
+
*/
|
|
27
|
+
export const SUBMIT_RETRY_BUDGET_MS = 5 * 60_000;
|
|
28
|
+
/**
|
|
29
|
+
* Statuses that leave the outcome unknown (502/503/504 from the edge or the
|
|
30
|
+
* API), say the API could not finish (500), or say the first attempt is still
|
|
31
|
+
* creating the run (409 SUBMISSION_IN_PROGRESS). Re-sending is only safe
|
|
32
|
+
* because the API dedupes on the upload id.
|
|
33
|
+
*/
|
|
34
|
+
const RETRYABLE_STATUSES = new Set([409, 500, 502, 503, 504]);
|
|
35
|
+
/**
|
|
36
|
+
* Whether a failed attempt is worth sending again. Anything that is not an API
|
|
37
|
+
* answer is a transport failure: a network error, the attempt timeout, or a
|
|
38
|
+
* body that died mid-read after a 2xx. In every case the run may already exist,
|
|
39
|
+
* which a repeat finds out. The npm build throws undici's errors and the bun
|
|
40
|
+
* binaries their own; neither is an ApiError, so both retry.
|
|
41
|
+
*/
|
|
42
|
+
export function isRetryableSubmitError(error) {
|
|
43
|
+
if (error instanceof ApiError)
|
|
44
|
+
return RETRYABLE_STATUSES.has(error.status);
|
|
45
|
+
// The session can't be refreshed (refused, or logged out): only `dcd login`
|
|
46
|
+
// fixes that, so another attempt would fail the same way.
|
|
47
|
+
if (isDefinitiveAuthFailure(error))
|
|
48
|
+
return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
/** A one-line reason for the retry notice. Network errors carry a long help text. */
|
|
52
|
+
export function describeSubmitError(error) {
|
|
53
|
+
if (error instanceof ApiError)
|
|
54
|
+
return `HTTP ${error.status}`;
|
|
55
|
+
if (error instanceof Error) {
|
|
56
|
+
if (error.name === 'SubmitTimeoutError')
|
|
57
|
+
return 'no response in time';
|
|
58
|
+
const code = error.cause?.code;
|
|
59
|
+
if (typeof code === 'string')
|
|
60
|
+
return code;
|
|
61
|
+
return error.name === 'NetworkError' ? 'network error' : error.name;
|
|
62
|
+
}
|
|
63
|
+
return String(error);
|
|
64
|
+
}
|
|
65
|
+
/** The API predates the client-direct submit: a real API 404s, some proxies 405. */
|
|
66
|
+
export function isSubmitEndpointMissing(error) {
|
|
67
|
+
return (error instanceof ApiError && (error.status === 404 || error.status === 405));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Send a submission, sending it again while its outcome is unknown.
|
|
71
|
+
*
|
|
72
|
+
* `fallback` is the legacy multipart submit for an API that predates this
|
|
73
|
+
* endpoint. It is used only when the *first* attempt says the endpoint is
|
|
74
|
+
* missing. A 404 on a later attempt comes from an API that took the earlier
|
|
75
|
+
* attempt, so a multipart submit then would create a second run.
|
|
76
|
+
*
|
|
77
|
+
* Resolves with the response and how many attempts it took. Throws the last
|
|
78
|
+
* error when it is not retryable or the attempts run out.
|
|
79
|
+
*/
|
|
80
|
+
export async function submitWithRetry(opts) {
|
|
81
|
+
const delaysMs = opts.delaysMs ?? SUBMIT_RETRY_DELAYS_MS;
|
|
82
|
+
const budgetMs = opts.budgetMs ?? SUBMIT_RETRY_BUDGET_MS;
|
|
83
|
+
const now = opts.now ?? Date.now;
|
|
84
|
+
const startedAt = now();
|
|
85
|
+
const sleep = opts.sleep ??
|
|
86
|
+
((ms) => new Promise((resolve) => {
|
|
87
|
+
setTimeout(resolve, ms);
|
|
88
|
+
}));
|
|
89
|
+
const attempts = delaysMs.length + 1;
|
|
90
|
+
for (let attempt = 1;; attempt++) {
|
|
91
|
+
opts.onAttempt?.(attempt);
|
|
92
|
+
try {
|
|
93
|
+
return { attempts: attempt, response: await opts.submit() };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (attempt === 1 && isSubmitEndpointMissing(error)) {
|
|
97
|
+
return {
|
|
98
|
+
attempts: attempt,
|
|
99
|
+
response: await opts.fallback(error),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const delayMs = delaysMs[attempt - 1];
|
|
103
|
+
if (delayMs === undefined ||
|
|
104
|
+
!isRetryableSubmitError(error) ||
|
|
105
|
+
now() - startedAt + delayMs > budgetMs) {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
opts.onRetry?.({
|
|
109
|
+
attempt: attempt + 1,
|
|
110
|
+
attempts,
|
|
111
|
+
delayMs,
|
|
112
|
+
reason: describeSubmitError(error),
|
|
113
|
+
});
|
|
114
|
+
await sleep(delayMs);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -13,6 +13,7 @@ declare class Telemetry {
|
|
|
13
13
|
private config;
|
|
14
14
|
private command;
|
|
15
15
|
private encryption;
|
|
16
|
+
private submitAttempts;
|
|
16
17
|
private sessionId;
|
|
17
18
|
private startedAt;
|
|
18
19
|
private readonly disabled;
|
|
@@ -46,12 +47,20 @@ declare class Telemetry {
|
|
|
46
47
|
* the meta fields it knows, but forwards every event's `extra`.
|
|
47
48
|
*/
|
|
48
49
|
recordEncryption(update: Partial<EncryptionTelemetry>): void;
|
|
50
|
+
/**
|
|
51
|
+
* Record how many attempts the flow submit took (dcd#1617 follow-up), sent
|
|
52
|
+
* as `submit_attempts` on `command completed` / `command failed`. Anything
|
|
53
|
+
* above 1 means a response was lost and the submit was sent again, which is
|
|
54
|
+
* what to count when judging how often the API stalls under the CLI.
|
|
55
|
+
*/
|
|
56
|
+
recordSubmitAttempts(attempts: number): void;
|
|
49
57
|
recordCommandSuccess(): void;
|
|
50
58
|
recordCommandFailure(opts: {
|
|
51
59
|
error: Error | string;
|
|
52
60
|
exitCode: number;
|
|
53
61
|
}): void;
|
|
54
62
|
private encryptionExtra;
|
|
63
|
+
private submitExtra;
|
|
55
64
|
private enqueue;
|
|
56
65
|
flush(): Promise<void>;
|
|
57
66
|
/**
|
|
@@ -24,6 +24,7 @@ class Telemetry {
|
|
|
24
24
|
config = null;
|
|
25
25
|
command = inferCommandFromArgv();
|
|
26
26
|
encryption = null;
|
|
27
|
+
submitAttempts;
|
|
27
28
|
sessionId = randomUUID();
|
|
28
29
|
startedAt = Date.now();
|
|
29
30
|
disabled = !!process.env.DCD_TELEMETRY_DISABLED;
|
|
@@ -94,11 +95,21 @@ class Telemetry {
|
|
|
94
95
|
...update,
|
|
95
96
|
};
|
|
96
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Record how many attempts the flow submit took (dcd#1617 follow-up), sent
|
|
100
|
+
* as `submit_attempts` on `command completed` / `command failed`. Anything
|
|
101
|
+
* above 1 means a response was lost and the submit was sent again, which is
|
|
102
|
+
* what to count when judging how often the API stalls under the CLI.
|
|
103
|
+
*/
|
|
104
|
+
recordSubmitAttempts(attempts) {
|
|
105
|
+
this.submitAttempts = attempts;
|
|
106
|
+
}
|
|
97
107
|
recordCommandSuccess() {
|
|
98
108
|
this.enqueue('info', 'cli.lifecycle', 'command completed', {
|
|
99
109
|
duration_ms: Date.now() - this.startedAt,
|
|
100
110
|
exit_code: 0,
|
|
101
111
|
...this.encryptionExtra(),
|
|
112
|
+
...this.submitExtra(),
|
|
102
113
|
});
|
|
103
114
|
}
|
|
104
115
|
recordCommandFailure(opts) {
|
|
@@ -110,11 +121,17 @@ class Telemetry {
|
|
|
110
121
|
error_name: opts.error instanceof Error ? opts.error.name : 'CliError',
|
|
111
122
|
error_stack: opts.error instanceof Error ? opts.error.stack : undefined,
|
|
112
123
|
...this.encryptionExtra(),
|
|
124
|
+
...this.submitExtra(),
|
|
113
125
|
});
|
|
114
126
|
}
|
|
115
127
|
encryptionExtra() {
|
|
116
128
|
return this.encryption ? { encrypt: this.encryption } : {};
|
|
117
129
|
}
|
|
130
|
+
submitExtra() {
|
|
131
|
+
return this.submitAttempts === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: { submit_attempts: this.submitAttempts };
|
|
134
|
+
}
|
|
118
135
|
enqueue(level, context, message, extra) {
|
|
119
136
|
if (this.disabled)
|
|
120
137
|
return;
|
package/package.json
CHANGED