fix: security release 2026-07-08 (#4316)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled

This commit is contained in:
Chris Arderne
2026-07-21 12:00:58 +01:00
committed by GitHub
parent cc748422d8
commit 6997aeb05e
141 changed files with 3939 additions and 324 deletions
+12 -2
View File
@@ -295,9 +295,10 @@ export async function login(options?: LoginOptions): Promise<LoginResult> {
const indexResult = await pRetry(
() => getPersonalAccessToken(apiClient, authorizationCodeResult.authorizationCode),
{
//this means we're polling, same distance between each attempt
//poll at a fixed 1s interval. ~5 min window so the user has time to
//approve the consent screen; stays within the code's 10-min validity.
factor: 1,
retries: 60,
retries: 300,
minTimeout: 1000,
}
);
@@ -404,6 +405,15 @@ export async function getPersonalAccessToken(apiClient: CliApiClient, authorizat
const token = await apiClient.getPersonalAccessToken(authorizationCode);
if (!token.success) {
// A 429 from the per-code poll rate limiter is transient: the auth code
// is still valid and the user may just not have approved the consent
// screen yet. Throw a regular (retryable) error so the poll loop backs
// off and keeps polling, rather than an AbortError, which pRetry treats
// as fatal and would abandon the whole login.
if (token.statusCode === 429) {
throw new Error(token.error);
}
throw new AbortError(token.error);
}
@@ -76,7 +76,7 @@ export class ManagedRunController {
});
const properties = {
...env.raw,
...env.rawForLogging,
TRIGGER_POD_SCHEDULED_AT_MS: env.TRIGGER_POD_SCHEDULED_AT_MS.toISOString(),
TRIGGER_DEQUEUED_AT_MS: env.TRIGGER_DEQUEUED_AT_MS.toISOString(),
};
@@ -27,6 +27,9 @@ const Env = z.object({
// Set at runtime
TRIGGER_DEPLOYMENT_ID: z.string(),
// Plain deployment friendlyId for telemetry. Optional: older supervisors don't set it, in which
// case we fall back to TRIGGER_DEPLOYMENT_ID.
TRIGGER_DEPLOYMENT_FRIENDLY_ID: z.string().optional(),
TRIGGER_DEPLOYMENT_VERSION: z.string(),
TRIGGER_WORKLOAD_CONTROLLER_ID: z.string().default(`controller_${randomUUID()}`),
TRIGGER_ENV_ID: z.string(),
@@ -74,6 +77,11 @@ export class RunnerEnv {
return this.env;
}
// TRIGGER_DEPLOYMENT_ID carries the deployment token; redact it before logging.
get rawForLogging() {
return { ...this.env, TRIGGER_DEPLOYMENT_ID: "[redacted]" };
}
// Base environment variables
get NODE_ENV() {
return this.env.NODE_ENV;
@@ -93,6 +101,9 @@ export class RunnerEnv {
get TRIGGER_DEPLOYMENT_ID() {
return this.env.TRIGGER_DEPLOYMENT_ID;
}
get TRIGGER_DEPLOYMENT_FRIENDLY_ID() {
return this.env.TRIGGER_DEPLOYMENT_FRIENDLY_ID;
}
get TRIGGER_DEPLOYMENT_VERSION() {
return this.env.TRIGGER_DEPLOYMENT_VERSION;
}
@@ -951,7 +951,7 @@ export class RunExecution {
this.sendDebugLog(`[override] processing: ${reason}`, {
overrides,
currentEnv: this.env.raw,
currentEnv: this.env.rawForLogging,
});
// Override the env with the new values
@@ -252,11 +252,14 @@ export class TaskRunProcessProvider {
workerManifest: this.workerManifest,
env: processEnv,
serverWorker: {
id: this.env.TRIGGER_DEPLOYMENT_ID,
// Telemetry-only (becomes OTel worker.id). Prefer the plain friendlyId; fall back to
// DEPLOYMENT_ID (which may be an opaque token) only when an older supervisor didn't set it.
id: this.env.TRIGGER_DEPLOYMENT_FRIENDLY_ID ?? this.env.TRIGGER_DEPLOYMENT_ID,
contentHash: this.env.TRIGGER_CONTENT_HASH,
version: this.env.TRIGGER_DEPLOYMENT_VERSION,
engine: "V2",
},
machineResources: {
cpu: Number(this.env.TRIGGER_MACHINE_CPU),
memory: Number(this.env.TRIGGER_MACHINE_MEMORY),
+3 -2
View File
@@ -123,9 +123,10 @@ export async function mcpAuth(options: McpAuthOptions): Promise<LoginResult> {
const indexResult = await pRetry(
() => getPersonalAccessToken(apiClient, authorizationCodeResult.authorizationCode),
{
//this means we're polling, same distance between each attempt
//poll at a fixed 1s interval. ~5 min window so the user has time to
//approve the consent screen; stays within the code's 10-min validity.
factor: 1,
retries: 60,
retries: 300,
minTimeout: 1000,
}
);
+8
View File
@@ -725,6 +725,13 @@ export type ApiResult<TSuccessResult> =
| {
success: false;
error: string;
/**
* HTTP status code, when the failure originated from an API response
* (e.g. 429 for rate limiting). Undefined for connection/transport
* errors that never reached the server. Lets callers distinguish
* transient failures worth retrying from fatal ones.
*/
statusCode?: number;
};
export async function wrapZodFetch<T extends z.ZodTypeAny>(
@@ -754,6 +761,7 @@ export async function wrapZodFetch<T extends z.ZodTypeAny>(
return {
success: false,
error: error.message,
statusCode: error.status,
};
} else if (error instanceof Error) {
return {
+1
View File
@@ -30,6 +30,7 @@ export * from "./resource-catalog-api.js";
export * from "./types/index.js";
export { links } from "./links.js";
export * from "./jwt.js";
export * from "./workloadDeploymentToken.js";
export * from "./idempotencyKeys.js";
export * from "./streams/asyncIterableStream.js";
export * from "./utils/getEnv.js";
+12 -4
View File
@@ -4,6 +4,10 @@ export type GenerateJWTOptions = {
secretKey: string;
payload: Record<string, any>;
expirationTime?: number | Date | string;
// Skip the `iat` claim. Combined with an absolute `expirationTime`, this makes the signed token a
// pure function of its payload — the same claims mint byte-identical tokens. Off by default so
// ordinary short-lived tokens keep their issued-at.
omitIssuedAt?: boolean;
};
export const JWT_ALGORITHM = "HS256";
@@ -15,13 +19,17 @@ export async function generateJWT(options: GenerateJWTOptions): Promise<string>
const secret = new TextEncoder().encode(options.secretKey);
return new SignJWT(options.payload)
const jwt = new SignJWT(options.payload)
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setProtectedHeader({ alg: JWT_ALGORITHM })
.setIssuedAt()
.setExpirationTime(options.expirationTime ?? "15m")
.sign(secret);
.setExpirationTime(options.expirationTime ?? "15m");
if (!options.omitIssuedAt) {
jwt.setIssuedAt();
}
return jwt.sign(secret);
}
export type ValidationResult =
@@ -3,6 +3,9 @@ export const WORKER_HEADERS = {
DEPLOYMENT_ID: "x-trigger-worker-deployment-id",
MANAGED_SECRET: "x-trigger-worker-managed-secret",
RUNNER_ID: "x-trigger-worker-runner-id",
// Verified environment id recovered from the deployment token. Set by the supervisor only when
// enforcing; the platform scopes the worker-action's snapshot read by it.
ENVIRONMENT_ID: "x-trigger-worker-environment-id",
};
export const WORKLOAD_HEADERS = {
@@ -143,7 +143,8 @@ export class SupervisorHttpClient {
runId: string,
snapshotId: string,
body: WorkerApiRunAttemptStartRequestBody,
runnerId?: string
runnerId?: string,
environmentId?: string
) {
return wrapZodFetch(
WorkerApiRunAttemptStartResponseBody,
@@ -153,6 +154,7 @@ export class SupervisorHttpClient {
headers: {
...this.defaultHeaders,
...this.runnerIdHeader(runnerId),
...this.environmentIdHeader(environmentId),
},
body: JSON.stringify(body),
}
@@ -163,7 +165,8 @@ export class SupervisorHttpClient {
runId: string,
snapshotId: string,
body: WorkerApiRunAttemptCompleteRequestBody,
runnerId?: string
runnerId?: string,
environmentId?: string
) {
return wrapZodFetch(
WorkerApiRunAttemptCompleteResponseBody,
@@ -173,13 +176,14 @@ export class SupervisorHttpClient {
headers: {
...this.defaultHeaders,
...this.runnerIdHeader(runnerId),
...this.environmentIdHeader(environmentId),
},
body: JSON.stringify(body),
}
);
}
async getLatestSnapshot(runId: string, runnerId?: string) {
async getLatestSnapshot(runId: string, runnerId?: string, environmentId?: string) {
return wrapZodFetch(
WorkerApiRunLatestSnapshotResponseBody,
`${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/latest`,
@@ -188,12 +192,18 @@ export class SupervisorHttpClient {
headers: {
...this.defaultHeaders,
...this.runnerIdHeader(runnerId),
...this.environmentIdHeader(environmentId),
},
}
);
}
async getSnapshotsSince(runId: string, snapshotId: string, runnerId?: string) {
async getSnapshotsSince(
runId: string,
snapshotId: string,
runnerId?: string,
environmentId?: string
) {
return wrapZodFetch(
WorkerApiRunSnapshotsSinceResponseBody,
`${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/since/${snapshotId}`,
@@ -202,6 +212,7 @@ export class SupervisorHttpClient {
headers: {
...this.defaultHeaders,
...this.runnerIdHeader(runnerId),
...this.environmentIdHeader(environmentId),
},
}
);
@@ -235,7 +246,12 @@ export class SupervisorHttpClient {
}
}
async continueRunExecution(runId: string, snapshotId: string, runnerId?: string) {
async continueRunExecution(
runId: string,
snapshotId: string,
runnerId?: string,
environmentId?: string
) {
return wrapZodFetch(
WorkerApiContinueRunExecutionRequestBody,
`${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/continue`,
@@ -244,6 +260,7 @@ export class SupervisorHttpClient {
headers: {
...this.defaultHeaders,
...this.runnerIdHeader(runnerId),
...this.environmentIdHeader(environmentId),
},
},
{
@@ -295,4 +312,10 @@ export class SupervisorHttpClient {
[WORKER_HEADERS.RUNNER_ID]: runnerId,
});
}
private environmentIdHeader(environmentId?: string): Record<string, string> {
return createHeaders({
[WORKER_HEADERS.ENVIRONMENT_ID]: environmentId,
});
}
}
@@ -29,4 +29,6 @@ export type WorkloadClientSocketData = {
runnerId: string;
runFriendlyId?: string;
snapshotId?: string;
// Deployment friendlyId resolved from the verified token; prefer over the raw deploymentId.
deploymentFriendlyId?: string;
};
@@ -1,5 +1,5 @@
import { JSONHeroPath } from "@jsonhero/path";
import type { RunMetadataChangeOperation } from "../schemas/common.js";
import { isSafeMetadataKey, type RunMetadataChangeOperation } from "../schemas/common.js";
import { dequal } from "dequal";
export type ApplyOperationResult = {
@@ -16,6 +16,13 @@ export function applyMetadataOperations(
let newMetadata: Record<string, unknown> = structuredClone(currentMetadata);
for (const operation of Array.isArray(operations) ? operations : [operations]) {
// Prevent unsafe JSON paths and direct __proto__ assignments from changing Object.prototype.
// ("update" carries no key.)
if (operation.type !== "update" && !isSafeMetadataKey(operation.key)) {
unappliedOperations.push(operation);
continue;
}
switch (operation.type) {
case "set": {
if (operation.key.startsWith("$.")) {
+24 -5
View File
@@ -4,6 +4,25 @@ import type { RuntimeEnvironmentType as DBRuntimeEnvironmentType } from "@trigge
export type Enum<T extends string> = { [K in T]: K };
const DANGEROUS_METADATA_KEY_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
/**
* Prototype-pollution guard for run metadata operation keys. JSON paths are applied via
* JSONHeroPath, so dangerous path segments must be rejected. Literal keys are assigned directly,
* where only __proto__ can change the target object's prototype.
*/
export function isSafeMetadataKey(key: string): boolean {
if (!key.startsWith("$.")) {
return key !== "__proto__";
}
return !key.split(/[.[\]'"]+/).some((segment) => DANGEROUS_METADATA_KEY_SEGMENTS.has(segment));
}
const MetadataOperationKey = z.string().refine(isSafeMetadataKey, {
message: "Metadata key may not reference __proto__, constructor, or prototype",
});
export const RunMetadataUpdateOperation = z.object({
type: z.literal("update"),
value: z.record(z.unknown()),
@@ -13,7 +32,7 @@ export type RunMetadataUpdateOperation = z.infer<typeof RunMetadataUpdateOperati
export const RunMetadataSetKeyOperation = z.object({
type: z.literal("set"),
key: z.string(),
key: MetadataOperationKey,
value: DeserializedJsonSchema,
});
@@ -21,14 +40,14 @@ export type RunMetadataSetKeyOperation = z.infer<typeof RunMetadataSetKeyOperati
export const RunMetadataDeleteKeyOperation = z.object({
type: z.literal("delete"),
key: z.string(),
key: MetadataOperationKey,
});
export type RunMetadataDeleteKeyOperation = z.infer<typeof RunMetadataDeleteKeyOperation>;
export const RunMetadataAppendKeyOperation = z.object({
type: z.literal("append"),
key: z.string(),
key: MetadataOperationKey,
value: DeserializedJsonSchema,
});
@@ -36,7 +55,7 @@ export type RunMetadataAppendKeyOperation = z.infer<typeof RunMetadataAppendKeyO
export const RunMetadataRemoveFromKeyOperation = z.object({
type: z.literal("remove"),
key: z.string(),
key: MetadataOperationKey,
value: DeserializedJsonSchema,
});
@@ -44,7 +63,7 @@ export type RunMetadataRemoveFromKeyOperation = z.infer<typeof RunMetadataRemove
export const RunMetadataIncrementKeyOperation = z.object({
type: z.literal("increment"),
key: z.string(),
key: MetadataOperationKey,
value: z.number(),
});
@@ -5,6 +5,10 @@ export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";
const DEFAULT_MAX_DEPTH = 128;
// This property name would let a crafted key walk into Object.prototype during
// reconstruction and pollute the shared process.
const PROTOTYPE_POLLUTION_KEY = "__proto__";
export function flattenAttributes(
obj: unknown,
prefix?: string,
@@ -297,6 +301,11 @@ export function unflattenAttributes(
continue;
}
// Skip any key whose path could walk into Object.prototype.
if (parts.includes(PROTOTYPE_POLLUTION_KEY)) {
continue;
}
let current: any = result;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
@@ -330,8 +339,10 @@ export function unflattenAttributes(
}
}
// Convert the result to an array if all top-level keys are numeric indices
if (Object.keys(result).every((k) => /^\d+$/.test(k))) {
// Convert the result to an array if all top-level keys are numeric indices.
// Guard against an empty result (e.g. every key was skipped as unsafe), which
// would otherwise produce Array(-Infinity) and throw.
if (Object.keys(result).length > 0 && Object.keys(result).every((k) => /^\d+$/.test(k))) {
const maxIndex = Math.max(...Object.keys(result).map((k) => parseInt(k)));
const arrayResult = Array(maxIndex + 1);
for (const key in result) {
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { generateJWT } from "./jwt.js";
import {
classifyDeploymentIdHeader,
looksLikeWorkloadDeploymentToken,
mintWorkloadDeploymentToken,
verifyWorkloadDeploymentToken,
WORKLOAD_DEPLOYMENT_TOKEN_VERSION,
type WorkloadDeploymentTokenInput,
} from "./workloadDeploymentToken.js";
const SECRET = "test-workload-token-secret";
const EXP = Math.floor(Date.UTC(2032, 0, 1) / 1000);
const claims: WorkloadDeploymentTokenInput = {
deployment: "deployment_abc123",
deployment_version: "20260709.1",
environment_id: "env_1",
environment_type: "PRODUCTION",
org_id: "org_1",
project_id: "proj_1",
};
describe("workloadDeploymentToken", () => {
it("mints a token that verifies and round-trips every claim", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const result = await verifyWorkloadDeploymentToken(token, SECRET);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.claims.ver).toBe(WORKLOAD_DEPLOYMENT_TOKEN_VERSION);
expect(result.claims.deployment).toBe(claims.deployment);
expect(result.claims.deployment_version).toBe(claims.deployment_version);
expect(result.claims.environment_id).toBe(claims.environment_id);
expect(result.claims.environment_type).toBe(claims.environment_type);
expect(result.claims.org_id).toBe(claims.org_id);
expect(result.claims.project_id).toBe(claims.project_id);
});
it("sets the caller-supplied absolute exp", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const result = await verifyWorkloadDeploymentToken(token, SECRET);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.claims.exp).toBe(EXP);
});
it("mints byte-identical tokens for identical claims (deterministic, no iat)", async () => {
const a = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const b = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
expect(a).toBe(b);
const result = await verifyWorkloadDeploymentToken(a, SECRET);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect((result.claims as Record<string, unknown>).iat).toBeUndefined();
});
it("rejects a token signed with a different secret", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const result = await verifyWorkloadDeploymentToken(token, "wrong-secret");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("invalid_signature");
});
it("rejects a tampered token", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const [header, payload, signature] = token.split(".");
const tampered = `${header}.${payload}x.${signature}`;
const result = await verifyWorkloadDeploymentToken(tampered, SECRET);
expect(result.ok).toBe(false);
});
it("rejects a valid JWT that is missing required claims", async () => {
// Signed with the right secret, but not a workload deployment token.
const token = await generateJWT({
secretKey: SECRET,
payload: { foo: "bar" },
expirationTime: "1825d",
});
const result = await verifyWorkloadDeploymentToken(token, SECRET);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("malformed_claims");
});
describe("looksLikeWorkloadDeploymentToken", () => {
it("is true for a minted token", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
expect(looksLikeWorkloadDeploymentToken(token)).toBe(true);
});
it("is false for a legacy bare friendlyId", () => {
expect(looksLikeWorkloadDeploymentToken("deployment_abc123")).toBe(false);
});
it("is false for empty and malformed values", () => {
expect(looksLikeWorkloadDeploymentToken("")).toBe(false);
expect(looksLikeWorkloadDeploymentToken("a.b")).toBe(false);
expect(looksLikeWorkloadDeploymentToken("a..c")).toBe(false);
});
});
describe("classifyDeploymentIdHeader", () => {
it("classifies a minted token as jwt_valid and returns claims", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const result = await classifyDeploymentIdHeader(token, SECRET);
expect(result.outcome).toBe("jwt_valid");
expect(result.claims?.deployment).toBe(claims.deployment);
});
it("classifies a JWT with a bad signature as jwt_invalid", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const result = await classifyDeploymentIdHeader(token, "wrong-secret");
expect(result.outcome).toBe("jwt_invalid");
expect(result.claims).toBeUndefined();
});
it("classifies a bare friendlyId as legacy_bare", async () => {
const result = await classifyDeploymentIdHeader("deployment_abc123", SECRET);
expect(result.outcome).toBe("legacy_bare");
});
});
});
@@ -0,0 +1,99 @@
import { z } from "zod";
import { generateJWT, validateJWT } from "./jwt.js";
/**
* Signed, deployment-scoped token carried in the TRIGGER_DEPLOYMENT_ID env var to identify a run
* controller. Long-lived identity token, not a rotating credential; the `ver` claim allows a future
* format revision.
*/
export const WORKLOAD_DEPLOYMENT_TOKEN_VERSION = 1 as const;
export const WorkloadDeploymentTokenClaims = z.object({
ver: z.literal(WORKLOAD_DEPLOYMENT_TOKEN_VERSION),
/** Deployment friendlyId (deployment_xxxx). */
deployment: z.string(),
deployment_version: z.string(),
environment_id: z.string(),
environment_type: z.string(),
org_id: z.string(),
project_id: z.string(),
exp: z.number(),
});
export type WorkloadDeploymentTokenClaims = z.infer<typeof WorkloadDeploymentTokenClaims>;
/** Claims the caller supplies; `ver` and `exp` are set by the minter. */
export type WorkloadDeploymentTokenInput = Omit<WorkloadDeploymentTokenClaims, "ver" | "exp">;
/**
* `expiresAtSeconds` is an absolute epoch (caller-supplied the supervisor drives it). Combined with
* the omitted `iat`, the token is byte-deterministic per deployment: identical claims mint identical
* bytes, so the OTel `worker.id` the runner derives from it stays one value per deployment.
*/
export async function mintWorkloadDeploymentToken(
claims: WorkloadDeploymentTokenInput,
secret: string,
expiresAtSeconds: number
): Promise<string> {
return generateJWT({
secretKey: secret,
payload: { ver: WORKLOAD_DEPLOYMENT_TOKEN_VERSION, ...claims },
expirationTime: expiresAtSeconds,
omitIssuedAt: true,
});
}
export type WorkloadDeploymentTokenVerification =
| { ok: true; claims: WorkloadDeploymentTokenClaims }
| { ok: false; reason: "invalid_signature" | "malformed_claims"; error: string };
export async function verifyWorkloadDeploymentToken(
token: string,
secret: string
): Promise<WorkloadDeploymentTokenVerification> {
const result = await validateJWT(token, secret);
if (!result.ok) {
return { ok: false, reason: "invalid_signature", error: result.error };
}
const parsed = WorkloadDeploymentTokenClaims.safeParse(result.payload);
if (!parsed.success) {
return { ok: false, reason: "malformed_claims", error: parsed.error.message };
}
return { ok: true, claims: parsed.data };
}
/**
* A legacy TRIGGER_DEPLOYMENT_ID is a bare friendlyId (deployment_xxxx); a minted token is a JWT of
* three non-empty base64url segments. Used by the dry-run to tell a pre-upgrade runner from a token.
*/
export function looksLikeWorkloadDeploymentToken(value: string): boolean {
const parts = value.split(".");
return parts.length === 3 && parts.every((part) => part.length > 0);
}
export type DeploymentIdHeaderOutcome = "jwt_valid" | "jwt_invalid" | "legacy_bare";
/**
* Classify the value carried in the deployment-id header for the rollout metric, verifying the
* signature when it is JWT-shaped. Returns the claims on a valid token so callers avoid a second verify.
*/
export async function classifyDeploymentIdHeader(
value: string,
secret: string
): Promise<{ outcome: DeploymentIdHeaderOutcome; claims?: WorkloadDeploymentTokenClaims }> {
if (!looksLikeWorkloadDeploymentToken(value)) {
return { outcome: "legacy_bare" };
}
const result = await verifyWorkloadDeploymentToken(value, secret);
if (result.ok) {
return { outcome: "jwt_valid", claims: result.claims };
}
return { outcome: "jwt_invalid" };
}
+4 -2
View File
@@ -1802,8 +1802,10 @@ describe("TaskExecutor", () => {
// Rate limit errors should use the rate limit retry delay
expect((result.result as any).retry.delay).toBeGreaterThan(0);
} else {
// Other retryable errors should use the exponential backoff
expect((result.result as any).retry.delay).toBeGreaterThan(1000);
// Other retryable errors should use the exponential backoff. The first retry uses
// minDelay (1000ms) as its base, and jitter can land exactly on the floor, so this
// is >= rather than > to avoid a flaky boundary failure.
expect((result.result as any).retry.delay).toBeGreaterThanOrEqual(1000);
expect((result.result as any).retry.delay).toBeLessThan(5000);
}
}