@@ -15,6 +15,10 @@ import { downloadAbi } from "./download-abi.command.ts";
1515let capturedOutput = "" ;
1616let originalWrite : typeof process . stdout . write ;
1717let 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
1923beforeEach ( 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+
3453const 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