Skip to content

Commit 8b7667c

Browse files
committed
fix(download-abi): enhance pagination handling and error management in download-abi command
- Improved the handling of pagination tokens in the `download-abi` command to ensure correct processing of continue tokens during retries. - Updated the test suite to verify the behavior of pagination and error scenarios, including handling of resource expiration and API rate limits. - Refactored the request structure to maintain consistency with the updated KubernetesClient interface.
1 parent a6ffb8e commit 8b7667c

File tree

2 files changed

+314
-69
lines changed

2 files changed

+314
-69
lines changed

src/cli/commands/download-abi/download-abi.command.test.ts

Lines changed: 164 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { downloadAbi } from "./download-abi.command.ts";
1515
let capturedOutput = "";
1616
let originalWrite: typeof process.stdout.write;
1717
let workingDirectory: string;
18+
const EXPECTED_LIMIT = 100;
19+
const RESOURCE_EXPIRED_STATUS = 410;
20+
const RATE_LIMIT_STATUS = 429;
21+
const noopPause = async () => Promise.resolve();
1822

1923
beforeEach(async () => {
2024
originalWrite = process.stdout.write;
@@ -31,17 +35,33 @@ afterEach(async () => {
3135
await rm(workingDirectory, { recursive: true, force: true });
3236
});
3337

38+
const readToken = (request: Record<string, unknown>): string | undefined => {
39+
const current = (request as { _continue?: unknown })._continue;
40+
if (typeof current === "string" && current.length > 0) {
41+
const legacy = (request as { continue?: unknown }).continue;
42+
if (legacy !== undefined && legacy !== current) {
43+
throw new Error(
44+
`Legacy continue token mismatch: expected ${current}, received ${legacy}`
45+
);
46+
}
47+
return current;
48+
}
49+
const legacy = (request as { continue?: unknown }).continue;
50+
return typeof legacy === "string" && legacy.length > 0 ? legacy : undefined;
51+
};
52+
3453
const createContext = (
3554
configMaps: readonly V1ConfigMap[]
3655
): KubernetesClient => ({
3756
namespace: "test-ns",
3857
client: {
39-
listNamespacedConfigMap: (request: {
40-
namespace: string;
41-
limit?: number;
42-
_continue?: string;
43-
}) => {
44-
if (request._continue) {
58+
listNamespacedConfigMap: (request: Record<string, unknown>) => {
59+
if ((request as { limit?: number }).limit !== EXPECTED_LIMIT) {
60+
throw new Error(
61+
`Expected limit ${EXPECTED_LIMIT}, received ${(request as { limit?: number }).limit}`
62+
);
63+
}
64+
if (readToken(request)) {
4565
return Promise.resolve({ body: { items: [], metadata: {} } });
4666
}
4767
return Promise.resolve({
@@ -62,13 +82,14 @@ const createPaginatedContext = (
6282
return {
6383
namespace: "test-ns",
6484
client: {
65-
listNamespacedConfigMap: (request: {
66-
namespace: string;
67-
limit?: number;
68-
_continue?: string;
69-
}) => {
85+
listNamespacedConfigMap: (request: Record<string, unknown>) => {
86+
if ((request as { limit?: number }).limit !== EXPECTED_LIMIT) {
87+
throw new Error(
88+
`Expected limit ${EXPECTED_LIMIT}, received ${(request as { limit?: number }).limit}`
89+
);
90+
}
7091
const expectedToken = tokens[callIndex];
71-
const providedToken = request._continue ?? undefined;
92+
const providedToken = readToken(request);
7293
if (providedToken !== expectedToken) {
7394
throw new Error(
7495
`Unexpected continue token: expected ${expectedToken}, received ${providedToken}`
@@ -106,7 +127,10 @@ describe("downloadAbi", () => {
106127

107128
await downloadAbi(
108129
{ outputDirectory: workingDirectory },
109-
{ createContext: () => Promise.resolve(createContext([configMap])) }
130+
{
131+
createContext: () => Promise.resolve(createContext([configMap])),
132+
pause: noopPause,
133+
}
110134
);
111135

112136
const filePath = join(workingDirectory, "abi-sample", "Sample.json");
@@ -130,7 +154,10 @@ describe("downloadAbi", () => {
130154

131155
await downloadAbi(
132156
{ outputDirectory: workingDirectory },
133-
{ createContext: () => Promise.resolve(createContext([configMap])) }
157+
{
158+
createContext: () => Promise.resolve(createContext([configMap])),
159+
pause: noopPause,
160+
}
134161
);
135162

136163
const entries = await readdir(workingDirectory);
@@ -172,10 +199,133 @@ describe("downloadAbi", () => {
172199
[undefined, "page-2", undefined]
173200
)
174201
),
202+
pause: noopPause,
175203
}
176204
);
177205

178206
const directories = await readdir(workingDirectory);
179207
expect(directories.sort()).toEqual(["abi-first", "abi-second"]);
180208
});
209+
210+
test("restarts pagination when Kubernetes expires the snapshot", async () => {
211+
const configMap: V1ConfigMap = {
212+
metadata: {
213+
name: "abi-retry",
214+
annotations: {
215+
[ARTIFACT_ANNOTATION_KEY]: ARTIFACT_VALUES.abi,
216+
},
217+
},
218+
data: {
219+
"Retry.json": "{}\n",
220+
},
221+
};
222+
223+
let callCount = 0;
224+
const context: KubernetesClient = {
225+
namespace: "test-ns",
226+
client: {
227+
listNamespacedConfigMap: (request: Record<string, unknown>) => {
228+
callCount += 1;
229+
if ((request as { limit?: number }).limit !== EXPECTED_LIMIT) {
230+
throw new Error(
231+
`Expected limit ${EXPECTED_LIMIT}, received ${(request as { limit?: number }).limit}`
232+
);
233+
}
234+
235+
if (callCount === 1) {
236+
const error = new Error("Expired snapshot") as Error & {
237+
statusCode?: number;
238+
};
239+
error.statusCode = RESOURCE_EXPIRED_STATUS;
240+
return Promise.reject(error);
241+
}
242+
243+
if (readToken(request)) {
244+
throw new Error(
245+
"Continue token should not be provided after resnapshot"
246+
);
247+
}
248+
249+
return Promise.resolve({
250+
body: {
251+
items: [configMap],
252+
metadata: {},
253+
},
254+
});
255+
},
256+
} as unknown as KubernetesClient["client"],
257+
};
258+
259+
await downloadAbi(
260+
{ outputDirectory: workingDirectory },
261+
{
262+
createContext: () => Promise.resolve(context),
263+
pause: noopPause,
264+
}
265+
);
266+
267+
const directories = await readdir(workingDirectory);
268+
expect(directories).toEqual(["abi-retry"]);
269+
expect(callCount).toBe(2);
270+
});
271+
272+
test("retries when the API rate limits requests", async () => {
273+
const configMap: V1ConfigMap = {
274+
metadata: {
275+
name: "abi-throttle",
276+
annotations: {
277+
[ARTIFACT_ANNOTATION_KEY]: ARTIFACT_VALUES.abi,
278+
},
279+
},
280+
data: {
281+
"Throttle.json": "{}\n",
282+
},
283+
};
284+
285+
let callIndex = 0;
286+
const context: KubernetesClient = {
287+
namespace: "test-ns",
288+
client: {
289+
listNamespacedConfigMap: (request: Record<string, unknown>) => {
290+
if ((request as { limit?: number }).limit !== EXPECTED_LIMIT) {
291+
throw new Error(
292+
`Expected limit ${EXPECTED_LIMIT}, received ${(request as { limit?: number }).limit}`
293+
);
294+
}
295+
296+
callIndex += 1;
297+
if (callIndex === 1) {
298+
const error = new Error("Too Many Requests") as Error & {
299+
statusCode?: number;
300+
};
301+
error.statusCode = RATE_LIMIT_STATUS;
302+
return Promise.reject(error);
303+
}
304+
305+
if (readToken(request)) {
306+
throw new Error("Unexpected continue token on successful retry");
307+
}
308+
309+
return Promise.resolve({
310+
body: {
311+
items: [configMap],
312+
metadata: {},
313+
},
314+
});
315+
},
316+
} as unknown as KubernetesClient["client"],
317+
};
318+
319+
await downloadAbi(
320+
{ outputDirectory: workingDirectory },
321+
{
322+
createContext: () => Promise.resolve(context),
323+
pause: noopPause,
324+
}
325+
);
326+
327+
const directories = await readdir(workingDirectory);
328+
expect(directories).toEqual(["abi-throttle"]);
329+
expect(callIndex).toBe(2);
330+
});
181331
});

0 commit comments

Comments
 (0)