Skip to content

Commit cb5c54c

Browse files
moshestclaude
andauthored
fix(registry): retry transient registry-server faults (#109)
One dropped connection out of ~58 packages was exiting the nightly publish non-zero. The two fetches to our own registry server had no retry, unlike the public-registry calls in version-check.ts; both now use the same p-retry pattern, with 4xx aborting immediately. Error messages carry the server's response body, which the existence check previously discarded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b6a3814 commit cb5c54c

2 files changed

Lines changed: 124 additions & 21 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { mkdtempSync, writeFileSync } from "node:fs";
2+
import { createServer, type Server } from "node:http";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { checkPackageExists, publishPackage } from "./publish.js";
7+
8+
/** A server that drops the first `failures` connections, then answers `status`. */
9+
function flakyServer(failures: number, status: number) {
10+
let seen = 0;
11+
const server = createServer((_req, res) => {
12+
seen++;
13+
if (seen <= failures) {
14+
res.socket?.destroy();
15+
return;
16+
}
17+
res.writeHead(status, { "Content-Type": "application/json" });
18+
res.end("{}");
19+
});
20+
21+
return new Promise<{ server: Server; url: string; hits: () => number }>(
22+
(resolve) => {
23+
server.listen(0, () => {
24+
const address = server.address();
25+
const port = typeof address === "object" && address ? address.port : 0;
26+
resolve({
27+
server,
28+
url: `http://127.0.0.1:${port}`,
29+
hits: () => seen,
30+
});
31+
});
32+
},
33+
);
34+
}
35+
36+
describe("publish", () => {
37+
let running: Server | undefined;
38+
39+
afterEach(() => {
40+
running?.close();
41+
running = undefined;
42+
});
43+
44+
const dbPath = join(mkdtempSync(join(tmpdir(), "publish-test-")), "pkg.db");
45+
writeFileSync(dbPath, "x");
46+
47+
// One dropped connection out of ~58 packages used to fail the whole nightly
48+
// publish, so transient faults have to survive rather than abort the run.
49+
it("retries a dropped connection instead of failing the package", async () => {
50+
const { server, url, hits } = await flakyServer(2, 404);
51+
running = server;
52+
process.env.REGISTRY_SERVER_URL = url;
53+
process.env.REGISTRY_PUBLISH_KEY = "test-key";
54+
55+
await expect(checkPackageExists("npm", "preact", "latest")).resolves.toBe(
56+
null,
57+
);
58+
expect(hits()).toBe(3);
59+
});
60+
61+
it("gives up immediately on a 4xx and keeps the server's message", async () => {
62+
const server = createServer((_req, res) => {
63+
res.writeHead(403);
64+
res.end("bad key");
65+
});
66+
running = server;
67+
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
68+
const address = server.address();
69+
const port = typeof address === "object" && address ? address.port : 0;
70+
process.env.REGISTRY_SERVER_URL = `http://127.0.0.1:${port}`;
71+
process.env.REGISTRY_PUBLISH_KEY = "test-key";
72+
73+
await expect(
74+
publishPackage("npm", "preact", "latest", dbPath),
75+
).rejects.toThrow(/403 Forbidden bad key/);
76+
});
77+
});

packages/registry/src/publish.ts

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,37 @@
77
*/
88

99
import { readFileSync } from "node:fs";
10+
import pRetry, { AbortError } from "p-retry";
1011

1112
const DEFAULT_SERVER_URL = "https://api.context.neuledge.com";
1213

14+
/**
15+
* The registry server occasionally drops a connection or returns 5xx under load.
16+
* A single blip used to fail the whole nightly publish — 48 packages succeed and
17+
* one `fetch failed` exits non-zero — so retry transient faults with backoff.
18+
* 4xx is the server's considered answer and aborts immediately.
19+
*/
20+
function requestWithRetry(
21+
url: string,
22+
init: RequestInit,
23+
describe: () => string,
24+
): Promise<Response> {
25+
return pRetry(
26+
async () => {
27+
const response = await fetch(url, init);
28+
if (response.ok || response.status === 404) return response;
29+
30+
const body = await response.text().catch(() => "");
31+
const error = new Error(
32+
`${describe()}: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`,
33+
);
34+
if (response.status < 500) throw new AbortError(error);
35+
throw error;
36+
},
37+
{ retries: 3 },
38+
);
39+
}
40+
1341
function getServerUrl(): string {
1442
return process.env.REGISTRY_SERVER_URL?.trim() || DEFAULT_SERVER_URL;
1543
}
@@ -48,18 +76,16 @@ export async function checkPackageExists(
4876
headers.Authorization = `Bearer ${key}`;
4977
}
5078

51-
const response = await fetch(url, { headers });
79+
const response = await requestWithRetry(
80+
url,
81+
{ headers },
82+
() => `Server error checking ${registry}/${name}@${version}`,
83+
);
5284

5385
if (response.status === 404) {
5486
return null;
5587
}
5688

57-
if (!response.ok) {
58-
throw new Error(
59-
`Server error checking ${registry}/${name}@${version}: ${response.status} ${response.statusText}`,
60-
);
61-
}
62-
6389
return (await response.json()) as PackageMetadata;
6490
}
6591

@@ -75,19 +101,19 @@ export async function publishPackage(
75101
const url = `${getServerUrl()}/packages/${encodeURIComponent(registry)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`;
76102
const body = readFileSync(dbPath);
77103

78-
const response = await fetch(url, {
79-
method: "POST",
80-
headers: {
81-
Authorization: `Bearer ${getPublishKey()}`,
82-
"Content-Type": "application/octet-stream",
104+
// Re-uploading an identical package is safe: the server keys on
105+
// registry/name/version, so a retry after a dropped connection overwrites
106+
// rather than duplicating.
107+
await requestWithRetry(
108+
url,
109+
{
110+
method: "POST",
111+
headers: {
112+
Authorization: `Bearer ${getPublishKey()}`,
113+
"Content-Type": "application/octet-stream",
114+
},
115+
body,
83116
},
84-
body,
85-
});
86-
87-
if (!response.ok) {
88-
const text = await response.text().catch(() => "");
89-
throw new Error(
90-
`Failed to publish ${registry}/${name}@${version}: ${response.status} ${response.statusText}${text ? ` — ${text}` : ""}`,
91-
);
92-
}
117+
() => `Failed to publish ${registry}/${name}@${version}`,
118+
);
93119
}

0 commit comments

Comments
 (0)