Skip to content

Commit 685c889

Browse files
authored
ci: add bounded transient live-smoke retry
1 parent 44e6ece commit 685c889

8 files changed

Lines changed: 390 additions & 68 deletions

File tree

‎.github/workflows/live-smoke.yml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ jobs:
4242
COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}
4343
COMETAPI_LIVE_SMOKE: "1"
4444
COMETAPI_SMOKE_MODEL: ${{ vars.COMETAPI_SMOKE_MODEL || 'gpt-5.6-sol' }}
45-
COMETAPI_LIVE_REQUEST_LIMIT: "3"
45+
COMETAPI_LIVE_RETRY_BUDGET: "1"
46+
COMETAPI_LIVE_REQUEST_LIMIT: "4"
4647
COMETAPI_LIVE_MAX_OUTPUT_TOKENS: "16"
4748
COMETAPI_LIVE_REQUEST_TIMEOUT_MS: "60000"
4849
COMETAPI_LIVE_CONCURRENCY: "1"
49-
# The test performs exactly three sequential requests (Models, Chat
50-
# non-streaming, Responses streaming), stops on the first failure, and
50+
# The test performs exactly three sequential logical requests (Models,
51+
# Chat non-streaming, Responses streaming), permits one global official
52+
# transient retry, stops on the first logical failure, and
5153
# never prints the credential.
5254
run: npm run test:live

‎.github/workflows/publish.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@ jobs:
706706
COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}
707707
COMETAPI_LIVE_SMOKE: "1"
708708
COMETAPI_SMOKE_MODEL: ${{ vars.COMETAPI_SMOKE_MODEL || 'gpt-5.6-sol' }}
709+
COMETAPI_LIVE_RETRY_BUDGET: "0"
709710
COMETAPI_LIVE_REQUEST_LIMIT: "3"
710711
COMETAPI_LIVE_MAX_OUTPUT_TOKENS: "16"
711712
COMETAPI_LIVE_REQUEST_TIMEOUT_MS: "60000"

‎RELEASING.md‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -279,11 +279,13 @@ The repository maintains four independently auditable workflows:
279279
and 24, with Node.js 26 advisory.
280280
- `live-smoke.yml`: trusted scheduled and manual default-branch-only low-cost
281281
compatibility tests against the pinned CometAPI HTTPS endpoint. Each run makes
282-
exactly three sequential requests, caps generated output at 16 tokens, applies
283-
a 60-second timeout to each request, fixes concurrency at one, and stops on the
284-
first failure. Standalone and release smoke jobs share one repository-wide
285-
concurrency group. Scheduled and manual live execution requires
286-
`LIVE_SMOKE_ENABLED=true`.
282+
exactly three sequential logical requests, permits one global official
283+
transient retry, caps total HTTP attempts at four, caps generated output at
284+
16 tokens, applies a 60-second timeout to each request, fixes concurrency at
285+
one, and stops on the first logical failure. Standalone and release smoke jobs
286+
share one repository-wide concurrency group. Scheduled and manual live
287+
execution requires `LIVE_SMOKE_ENABLED=true`. The release-tag smoke sets the
288+
retry budget to zero and retains a three-attempt limit.
287289
- `release-please.yml`: patch-only versioning, an explicit `cometapi` branch
288290
component, separate pull requests, and the pinned root-package title
289291
`chore(main): release <version>`. It requires
@@ -414,8 +416,9 @@ dist-tag. Before authorizing release:
414416
immutable GitHub prerelease.
415417
9. Let the release workflow verify `immutable: true`, `main` ancestry, the
416418
exact tarball, and the trusted release-tag live smoke: exactly three
417-
sequential requests, 16 output tokens, 60 seconds per request, concurrency
418-
one, and stop on the first failure.
419+
sequential logical requests, a zero retry budget, three HTTP attempts, 16
420+
output tokens, 60 seconds per request, concurrency one, and stop on the
421+
first logical failure.
419422
10. Approve publication of that same tarball through the protected `npm`
420423
environment with OIDC and provenance, or use the one-time alpha.1 bootstrap
421424
only when normal Trusted Publishing cannot create the new package. The

‎ROADMAP.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,9 @@ The readiness criteria remain:
185185
requires approval by the current release approver and permits self-review.
186186
- Default-branch CI is rerun successfully, the fail-closed content gate reports
187187
all violations together and then passes, and an explicitly authorized
188-
protected live smoke passes within the three-request, 16-output-token,
189-
60-second-per-request, concurrency-one, stop-on-first-failure budget.
188+
protected live smoke passes within the three-logical-request, one-global-
189+
retry, four-HTTP-attempt, 16-output-token, 60-second-per-request,
190+
concurrency-one, stop-on-first-logical-failure budget.
190191

191192
## 0.1.0-alpha.1: Registry Alpha
192193

@@ -432,8 +433,11 @@ The repository will maintain:
432433
- `ci.yml` for offline pull-request and default-branch checks.
433434
- `live-smoke.yml` for budgeted trusted compatibility tests from the default
434435
branch to the pinned CometAPI HTTPS endpoint. Each run is fixed at exactly
435-
three sequential requests, a 16-token output cap, a 60-second per-request
436-
timeout, concurrency one, and stop on the first failure.
436+
three sequential logical requests, permits one global official transient
437+
retry, caps total HTTP attempts at four, uses a 16-token output cap and a
438+
60-second per-request timeout, fixes concurrency at one, and stops on the
439+
first logical failure. The release-tag smoke sets the retry budget to zero
440+
and retains a three-attempt limit.
437441
- `release-please.yml` for the reviewed version and changelog PR and the
438442
corresponding immutable tag and GitHub Release.
439443
- `publish.yml` for trusted Release Please completion, immutable-release and

‎scripts/live-smoke.mjs‎

Lines changed: 149 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import assert from "node:assert/strict";
2+
import {
3+
APIConnectionError,
4+
APIConnectionTimeoutError,
5+
APIError,
6+
} from "openai";
27

38
import { CometAPI } from "../dist/index.js";
49

@@ -9,17 +14,19 @@ const ALLOWED_URLS = new Set([
914
`${LIVE_BASE_URL}/responses`,
1015
]);
1116

12-
function readBudget(name, maximum) {
17+
function readBudget(name, maximum, minimum = 1) {
1318
const value = Number(process.env[name] ?? maximum);
14-
if (!Number.isInteger(value) || value < 1 || value > maximum) {
19+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
1520
throw new Error(
16-
`${name} must be an integer between 1 and ${String(maximum)}.`,
21+
`${name} must be an integer between ${String(minimum)} and ${String(maximum)}.`,
1722
);
1823
}
1924
return value;
2025
}
2126

22-
const REQUEST_LIMIT = readBudget("COMETAPI_LIVE_REQUEST_LIMIT", 3);
27+
const LOGICAL_REQUEST_COUNT = 3;
28+
const RETRY_BUDGET = readBudget("COMETAPI_LIVE_RETRY_BUDGET", 1, 0);
29+
const REQUEST_LIMIT = readBudget("COMETAPI_LIVE_REQUEST_LIMIT", 4);
2330
const OUTPUT_TOKEN_LIMIT = readBudget("COMETAPI_LIVE_MAX_OUTPUT_TOKENS", 16);
2431
const REQUEST_TIMEOUT_MS = readBudget(
2532
"COMETAPI_LIVE_REQUEST_TIMEOUT_MS",
@@ -35,8 +42,13 @@ if (
3542
throw new Error(`The live smoke endpoint is pinned to ${LIVE_BASE_URL}.`);
3643
}
3744

38-
if (REQUEST_LIMIT !== 3 || concurrency !== 1) {
39-
throw new Error("The 0.1 live smoke requires exactly 3 sequential requests.");
45+
if (
46+
REQUEST_LIMIT !== LOGICAL_REQUEST_COUNT + RETRY_BUDGET ||
47+
concurrency !== 1
48+
) {
49+
throw new Error(
50+
"The 0.1 live smoke requires 3 sequential requests and a request limit equal to the logical count plus the global retry budget.",
51+
);
4052
}
4153

4254
if (process.env.COMETAPI_LIVE_SMOKE !== "1") {
@@ -52,6 +64,89 @@ if (!process.env.COMETAPI_KEY) {
5264
}
5365

5466
let requestCount = 0;
67+
let retryBudget = RETRY_BUDGET;
68+
69+
function classifyError(error) {
70+
let category = "unknown";
71+
let status = null;
72+
if (error instanceof APIConnectionTimeoutError) {
73+
category = "timeout";
74+
} else if (error instanceof APIConnectionError) {
75+
category = "connection";
76+
}
77+
if (
78+
category === "unknown" &&
79+
error instanceof APIError &&
80+
Number.isInteger(error.status)
81+
) {
82+
status = error.status;
83+
category = "http";
84+
}
85+
if (error instanceof globalThis.DOMException && error.name === "AbortError") {
86+
category = "abort";
87+
}
88+
if (error instanceof Error) {
89+
const code = error.code ?? error.cause?.code;
90+
if (
91+
typeof code === "string" &&
92+
[
93+
"ECONNABORTED",
94+
"ECONNREFUSED",
95+
"ECONNRESET",
96+
"EAI_AGAIN",
97+
"ENETUNREACH",
98+
"ENOTFOUND",
99+
"ETIMEDOUT",
100+
].includes(code)
101+
) {
102+
category = "transport";
103+
}
104+
}
105+
const transient =
106+
category === "timeout" ||
107+
category === "connection" ||
108+
category === "abort" ||
109+
category === "transport" ||
110+
(category === "http" &&
111+
(status === 408 || status === 429 || (status !== null && status >= 500)));
112+
return { category, status, transient };
113+
}
114+
115+
async function runLogicalRequest(label, operation) {
116+
for (let attempt = 1; attempt <= 2; attempt += 1) {
117+
const startedAt = Date.now();
118+
try {
119+
const result = await operation();
120+
console.error(
121+
JSON.stringify({
122+
attempt,
123+
category: "success",
124+
elapsed_ms: Date.now() - startedAt,
125+
request: label,
126+
status: null,
127+
}),
128+
);
129+
return result;
130+
} catch (error) {
131+
const classification = classifyError(error);
132+
console.error(
133+
JSON.stringify({
134+
attempt,
135+
category: classification.category,
136+
elapsed_ms: Date.now() - startedAt,
137+
request: label,
138+
status: classification.status,
139+
}),
140+
);
141+
if (retryBudget === 0 || !classification.transient) {
142+
throw error;
143+
}
144+
retryBudget -= 1;
145+
}
146+
}
147+
throw new Error("The live smoke retry loop exhausted unexpectedly.");
148+
}
149+
55150
const guardedFetch = async (input, init) => {
56151
requestCount += 1;
57152
if (requestCount > REQUEST_LIMIT) {
@@ -82,15 +177,17 @@ const client = new CometAPI({
82177
timeout: REQUEST_TIMEOUT_MS,
83178
});
84179

85-
const models = await client.models.list();
180+
const models = await runLogicalRequest("Models", () => client.models.list());
86181
assert.ok(models.data.length > 0, "models.list returned no models");
87182

88-
const chat = await client.chat.completions.create({
89-
max_completion_tokens: OUTPUT_TOKEN_LIMIT,
90-
messages: [{ content: "Reply with OK.", role: "user" }],
91-
model,
92-
reasoning_effort: "none",
93-
});
183+
const chat = await runLogicalRequest("Chat Completions", () =>
184+
client.chat.completions.create({
185+
max_completion_tokens: OUTPUT_TOKEN_LIMIT,
186+
messages: [{ content: "Reply with OK.", role: "user" }],
187+
model,
188+
reasoning_effort: "none",
189+
}),
190+
);
94191
assert.ok(chat.id, "Chat Completions returned no response ID");
95192
const chatChoice = chat.choices[0];
96193
assert.ok(chatChoice, "Chat Completions returned no choices");
@@ -104,41 +201,48 @@ assert.ok(
104201
"Chat Completions returned no assistant text",
105202
);
106203

107-
const stream = await client.responses.create({
108-
input: "Reply with OK.",
109-
max_output_tokens: OUTPUT_TOKEN_LIMIT,
110-
model,
111-
reasoning: { effort: "none" },
112-
stream: true,
113-
});
114-
let eventCount = 0;
115-
let responseCompleted = false;
116-
let responseText = "";
117-
for await (const event of stream) {
118-
eventCount += 1;
119-
if (
120-
event.type === "error" ||
121-
event.type === "response.failed" ||
122-
event.type === "response.incomplete"
123-
) {
124-
throw new Error(`Responses stream ended with ${event.type}.`);
204+
const response = await runLogicalRequest("Responses", async () => {
205+
const stream = await client.responses.create({
206+
input: "Reply with OK.",
207+
max_output_tokens: OUTPUT_TOKEN_LIMIT,
208+
model,
209+
reasoning: { effort: "none" },
210+
stream: true,
211+
});
212+
let eventCount = 0;
213+
let responseCompleted = false;
214+
let responseText = "";
215+
for await (const event of stream) {
216+
eventCount += 1;
217+
if (
218+
event.type === "error" ||
219+
event.type === "response.failed" ||
220+
event.type === "response.incomplete"
221+
) {
222+
throw new Error(`Responses stream ended with ${event.type}.`);
223+
}
224+
if (event.type === "response.output_text.delta") {
225+
responseText += event.delta;
226+
}
227+
if (event.type === "response.completed") {
228+
assert.equal(
229+
event.response.status,
230+
"completed",
231+
"Responses terminal event was not completed",
232+
);
233+
responseCompleted = true;
234+
}
125235
}
126-
if (event.type === "response.output_text.delta") {
127-
responseText += event.delta;
128-
}
129-
if (event.type === "response.completed") {
130-
assert.equal(
131-
event.response.status,
132-
"completed",
133-
"Responses terminal event was not completed",
134-
);
135-
responseCompleted = true;
136-
}
137-
}
236+
return { eventCount, responseCompleted, responseText };
237+
});
238+
const { eventCount, responseCompleted, responseText } = response;
138239
assert.ok(eventCount > 0, "Responses stream returned no events");
139240
assert.ok(responseCompleted, "Responses stream returned no completed event");
140241
assert.ok(responseText.trim(), "Responses stream returned no output text");
141-
assert.equal(requestCount, REQUEST_LIMIT);
242+
assert.ok(
243+
requestCount >= LOGICAL_REQUEST_COUNT && requestCount <= REQUEST_LIMIT,
244+
`Live smoke used ${String(requestCount)} HTTP attempts for ${String(LOGICAL_REQUEST_COUNT)} logical requests.`,
245+
);
142246
console.log(
143-
`Live smoke passed ${String(requestCount)} sequential requests with a ${String(OUTPUT_TOKEN_LIMIT)}-token output cap.`,
247+
`Live smoke passed ${String(LOGICAL_REQUEST_COUNT)} sequential requests in ${String(requestCount)} HTTP attempts with a ${String(OUTPUT_TOKEN_LIMIT)}-token output cap.`,
144248
);

‎scripts/release-workflow-validation.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const RELEASE_WORKFLOW_STEP = "Run Release Please";
1818
const PUBLISH_OPERATION = "release";
1919
const NPM_TAG_POLICY_ID = 55718965;
2020
const PUBLISH_WORKFLOW_CONTRACT_SHA256 =
21-
"081af3a47c0bef1ff38a77cf038ab3c66b8c14ea2b524c42bc4f2c21e41f9f79";
21+
"684e15c13835ae3b139929ae78e5bbd5ba618ea8f30ebba8dbb4c42004d57a06";
2222
const PUBLISH_HANDOFF_IF =
2323
"vars.RELEASE_PLEASE_ENABLED == 'true' && github.event_name == 'workflow_run' && github.run_attempt == 1 && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'";
2424
const PUBLISH_RESULT_IF = "steps.result.outputs.has-result == 'true'";
@@ -570,6 +570,7 @@ fi
570570
COMETAPI_KEY: "${{ secrets.COMETAPI_KEY }}",
571571
COMETAPI_LIVE_SMOKE: "1",
572572
COMETAPI_SMOKE_MODEL: "${{ vars.COMETAPI_SMOKE_MODEL || 'gpt-5.6-sol' }}",
573+
COMETAPI_LIVE_RETRY_BUDGET: "0",
573574
COMETAPI_LIVE_REQUEST_LIMIT: "3",
574575
COMETAPI_LIVE_MAX_OUTPUT_TOKENS: "16",
575576
COMETAPI_LIVE_REQUEST_TIMEOUT_MS: "60000",

0 commit comments

Comments
 (0)