kubernetes-fluent-client 3.11.6 → 3.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fetch.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RequestInfo, RequestInit } from "undici";
1
+ import { Headers, RequestInfo, RequestInit } from "undici";
2
2
  export type FetchResponse<T> = {
3
3
  data: T;
4
4
  ok: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwB,WAAW,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAExE,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI;IAC7B,IAAI,EAAE,CAAC,CAAC;IACR,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,CAAC,CAAC,EAAE,OAAO,CAAC;CACb,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,wBAAsB,KAAK,CAAC,CAAC,EAC3B,GAAG,EAAE,GAAG,GAAG,WAAW,EACtB,IAAI,CAAC,EAAE,WAAW,GACjB,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CA2C3B"}
1
+ {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAEjF,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI;IAC7B,IAAI,EAAE,CAAC,CAAC;IACR,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,CAAC,CAAC,EAAE,OAAO,CAAC;CACb,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,wBAAsB,KAAK,CAAC,CAAC,EAC3B,GAAG,EAAE,GAAG,GAAG,WAAW,EACtB,IAAI,CAAC,EAAE,WAAW,GACjB,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CA2C3B"}
package/dist/fetch.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors
3
3
  import { StatusCodes } from "http-status-codes";
4
- import { fetch as undiciFetch } from "undici";
4
+ import { fetch as undiciFetch, Headers } from "undici";
5
5
  /**
6
6
  * Perform an async HTTP call and return the parsed JSON response, optionally
7
7
  * as a specific type.
@@ -39,7 +39,7 @@ export async function fetch(url, init) {
39
39
  // Otherwise, return however the response was read
40
40
  data = (await resp.text());
41
41
  }
42
- return { data, ok, status, statusText, headers };
42
+ return { data, ok, status, statusText, headers: headers };
43
43
  }
44
44
  catch (e) {
45
45
  // Always treat a catch as a failure for callers — even when the HTTP
@@ -1 +1 @@
1
- {"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../src/fluent/watch.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,EAAE,YAAY,EAAwB,MAAM,aAAa,CAAC;AACjE,OAAO,EAEL,OAAO,EAEP,WAAW,EAEZ,MAAM,mBAAmB,CAAC;AAG3B,oBAAY,UAAU;IACpB,sCAAsC;IACtC,OAAO,YAAY;IACnB,2BAA2B;IAC3B,aAAa,kBAAkB;IAC/B,kDAAkD;IAClD,UAAU,eAAe;IACzB,0BAA0B;IAC1B,SAAS,cAAc;IACvB,8BAA8B;IAC9B,OAAO,YAAY;IACnB,sBAAsB;IACtB,KAAK,UAAU;IACf,mCAAmC;IACnC,IAAI,SAAS;IACb,wCAAwC;IACxC,oBAAoB,yBAAyB;IAC7C,qCAAqC;IACrC,iBAAiB,sBAAsB;IACvC,kCAAkC;IAClC,IAAI,SAAS;IACb,2BAA2B;IAC3B,UAAU,eAAe;IACzB,mBAAmB;IACnB,UAAU,eAAe;IACzB,qCAAqC;IACrC,wBAAwB,6BAA6B;IACrD,iCAAiC;IACjC,eAAe,oBAAoB;IACnC,4BAA4B;IAC5B,WAAW,gBAAgB;CAC5B;AAED,4CAA4C;AAC5C,MAAM,MAAM,QAAQ,GAAG;IACrB,+HAA+H;IAC/H,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+FAA+F;IAC/F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wHAAwH;IACxH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAKF,iDAAiD;AACjD,qBAAa,OAAO,CAAC,CAAC,SAAS,YAAY;;IAyBzC,YAAY,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC;IAc9B;;;;;;;;;;;OAWG;gBACS,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,QAAQ,GAAE,QAAa;IA0CzF;;;;OAIG;IACU,KAAK,IAAI,OAAO,CAAC,eAAe,CAAC;IAO9C,gGAAgG;IACzF,KAAK;IAQZ;;;;;;OAMG;IACH,IAAW,MAAM,IAAI,YAAY,CAEhC;CAuaF"}
1
+ {"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../src/fluent/watch.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,EAAE,YAAY,EAAwB,MAAM,aAAa,CAAC;AACjE,OAAO,EAEL,OAAO,EAEP,WAAW,EAEZ,MAAM,mBAAmB,CAAC;AAG3B,oBAAY,UAAU;IACpB,sCAAsC;IACtC,OAAO,YAAY;IACnB,2BAA2B;IAC3B,aAAa,kBAAkB;IAC/B,kDAAkD;IAClD,UAAU,eAAe;IACzB,0BAA0B;IAC1B,SAAS,cAAc;IACvB,8BAA8B;IAC9B,OAAO,YAAY;IACnB,sBAAsB;IACtB,KAAK,UAAU;IACf,mCAAmC;IACnC,IAAI,SAAS;IACb,wCAAwC;IACxC,oBAAoB,yBAAyB;IAC7C,qCAAqC;IACrC,iBAAiB,sBAAsB;IACvC,kCAAkC;IAClC,IAAI,SAAS;IACb,2BAA2B;IAC3B,UAAU,eAAe;IACzB,mBAAmB;IACnB,UAAU,eAAe;IACzB,qCAAqC;IACrC,wBAAwB,6BAA6B;IACrD,iCAAiC;IACjC,eAAe,oBAAoB;IACnC,4BAA4B;IAC5B,WAAW,gBAAgB;CAC5B;AAED,4CAA4C;AAC5C,MAAM,MAAM,QAAQ,GAAG;IACrB,+HAA+H;IAC/H,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+FAA+F;IAC/F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wHAAwH;IACxH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAKF,iDAAiD;AACjD,qBAAa,OAAO,CAAC,CAAC,SAAS,YAAY;;IAyBzC,YAAY,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC;IAiB9B;;;;;;;;;;;OAWG;gBACS,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,QAAQ,GAAE,QAAa;IA+CzF;;;;OAIG;IACU,KAAK,IAAI,OAAO,CAAC,eAAe,CAAC;IAO9C,gGAAgG;IACzF,KAAK;IAQZ;;;;;;OAMG;IACH,IAAW,MAAM,IAAI,YAAY,CAEhC;CAgdF"}
@@ -4,7 +4,7 @@ import { EventEmitter } from "events";
4
4
  import { Readable } from "stream";
5
5
  import { fetch } from "undici";
6
6
  import { FetchMethods, WatchPhase, } from "./shared-types.js";
7
- import { getHeaders, k8sCfg, pathBuilder, sleep, startSleep } from "./utils.js";
7
+ import { getHeaders, k8sCfg, pathBuilder, sleep } from "./utils.js";
8
8
  export var WatchEvent;
9
9
  (function (WatchEvent) {
10
10
  /** Watch is connected successfully */
@@ -65,6 +65,8 @@ export class Watcher {
65
65
  #resyncTimer;
66
66
  // Track if a reconnect is pending
67
67
  #pendingReconnect = false;
68
+ // Track if a list operation is in progress to prevent concurrent lists
69
+ #listInProgress = false;
68
70
  // The resource version to start the watch at, this will be updated after the list operation.
69
71
  #resourceVersion;
70
72
  // Track the list of items in the cache
@@ -98,7 +100,12 @@ export class Watcher {
98
100
  this.$relistTimer = setInterval(() => {
99
101
  this.#latestRelistWindow = new Date().toISOString();
100
102
  this.#events.emit(WatchEvent.INIT_CACHE_MISS, this.#latestRelistWindow);
101
- void this.#list();
103
+ void (async () => {
104
+ const success = await this.#list();
105
+ if (!success) {
106
+ this.#lastSeenTime = OVERRIDE;
107
+ }
108
+ })();
102
109
  }, watchCfg.relistIntervalSec * 1000 + jitter);
103
110
  // Rebuild the watch every resync delay interval
104
111
  this.#resyncTimer = setInterval(this.#checkResync, watchCfg.resyncDelaySec * 1000 + jitter);
@@ -176,9 +183,17 @@ export class Watcher {
176
183
  * @param continueToken - the continue token for the list
177
184
  * @param removedItems - the list of items that have been removed
178
185
  * @param retryCount - current retry attempt count
186
+ * @param pageCount - number of continuation pages already fetched (0-based)
179
187
  * @returns resolves when list processing is complete
180
188
  */
181
- #list = async (continueToken, removedItems, retryCount = 0) => {
189
+ #list = async (continueToken, removedItems, retryCount = 0, pageCount = 0) => {
190
+ const isTopLevel = !continueToken && !removedItems && retryCount === 0;
191
+ if (isTopLevel) {
192
+ if (this.#listInProgress) {
193
+ return false;
194
+ }
195
+ this.#listInProgress = true;
196
+ }
182
197
  const maxRetries = 5;
183
198
  const maxPages = 10;
184
199
  try {
@@ -192,18 +207,16 @@ export class Watcher {
192
207
  const retryAfterHeader = response.headers.get("retry-after");
193
208
  // Retry with exponential backoff if under retry limit to prevent infinite recursion if the server is returning errors
194
209
  if (retryCount < maxRetries && retryAfterHeader) {
195
- const backoffTime = retryAfterHeader
196
- ? parseInt(retryAfterHeader) * 1000
197
- : Math.min(startSleep * Math.pow(2, retryCount), 30000);
210
+ const backoffTime = parseInt(retryAfterHeader) * 1000;
198
211
  await sleep(backoffTime);
199
212
  try {
200
- return this.#list(continueToken, removedItems, retryCount + 1);
213
+ return await this.#list(continueToken, removedItems, retryCount + 1, pageCount);
201
214
  }
202
215
  catch (e) {
203
216
  this.#events.emit(WatchEvent.LIST_ERROR, `retry list failed attempt ${retryCount}: ${e}`);
204
217
  }
205
218
  }
206
- return;
219
+ return false;
207
220
  }
208
221
  // Unconditionally assign the continue token from the response so that it is cleared on the last page
209
222
  continueToken = list.metadata.continue;
@@ -213,6 +226,7 @@ export class Watcher {
213
226
  this.#resourceVersion = list.metadata?.resourceVersion;
214
227
  // If removed items are not provided, clone the cache
215
228
  removedItems = removedItems || new Map(this.#cache.entries());
229
+ let anyCallbackFailed = false;
216
230
  // Process each item in the list
217
231
  for (const item of list.items || []) {
218
232
  const { uid } = item.metadata;
@@ -221,8 +235,15 @@ export class Watcher {
221
235
  // If the item does not exist, it is new and should be added
222
236
  if (!alreadyExists) {
223
237
  this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
224
- // Send added event. Use void here because we don't care about the result (no consequences here if it fails)
225
- void this.#process(item, WatchPhase.Added);
238
+ try {
239
+ // Keep lastSeenTime fresh to prevent spurious resync during slow list processing
240
+ this.#lastSeenTime = Date.now();
241
+ await this.#process(item, WatchPhase.Added);
242
+ }
243
+ catch (err) {
244
+ anyCallbackFailed = true;
245
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
246
+ }
226
247
  continue;
227
248
  }
228
249
  // Check if the resource version has changed for items that already exist
@@ -231,30 +252,53 @@ export class Watcher {
231
252
  // Check if the resource version is newer than the cached version
232
253
  if (itemRV > cachedRV) {
233
254
  this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
234
- // Send a modified event if the resource version has changed
235
- void this.#process(item, WatchPhase.Modified);
255
+ try {
256
+ this.#lastSeenTime = Date.now();
257
+ await this.#process(item, WatchPhase.Modified);
258
+ }
259
+ catch (err) {
260
+ anyCallbackFailed = true;
261
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
262
+ }
236
263
  }
237
264
  }
238
265
  // If there is a continue token, call the list function again with the same removed items
239
266
  if (continueToken) {
240
267
  // Safeguard against infinite pagination
241
- if (retryCount >= maxPages) {
268
+ if (pageCount >= maxPages) {
242
269
  this.#events.emit(WatchEvent.LIST_ERROR, new Error(`Maximum pagination limit (${maxPages}) reached, stopping list operation`));
243
- return;
270
+ return false;
244
271
  }
245
- // Continue pagination (not a retry, so reset retryCount to 0)
246
- await this.#list(continueToken, removedItems, 0);
272
+ // Continue pagination (not a retry, so reset retryCount to 0).
273
+ // Combine with current page's failure state so a callback failure on
274
+ // an earlier page is not masked by a successful later page.
275
+ const nextPageSucceeded = await this.#list(continueToken, removedItems, 0, pageCount + 1);
276
+ return !anyCallbackFailed && nextPageSucceeded;
247
277
  }
248
278
  else {
249
279
  // Otherwise, process the removed items
250
280
  for (const item of removedItems.values()) {
251
281
  this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
252
- void this.#process(item, WatchPhase.Deleted);
282
+ try {
283
+ this.#lastSeenTime = Date.now();
284
+ await this.#process(item, WatchPhase.Deleted);
285
+ }
286
+ catch (err) {
287
+ anyCallbackFailed = true;
288
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
289
+ }
253
290
  }
254
291
  }
292
+ return !anyCallbackFailed;
255
293
  }
256
294
  catch (err) {
257
295
  this.#events.emit(WatchEvent.LIST_ERROR, err);
296
+ return false;
297
+ }
298
+ finally {
299
+ if (isTopLevel) {
300
+ this.#listInProgress = false;
301
+ }
258
302
  }
259
303
  };
260
304
  /**
@@ -264,26 +308,21 @@ export class Watcher {
264
308
  * @param phase - the event phase
265
309
  */
266
310
  #process = async (payload, phase) => {
267
- try {
268
- switch (phase) {
269
- // If the event is added or modified, update the cache
270
- case WatchPhase.Added:
271
- case WatchPhase.Modified:
272
- this.#cache.set(payload.metadata.uid, payload);
273
- break;
274
- // If the event is deleted, remove the item from the cache
275
- case WatchPhase.Deleted:
276
- this.#cache.delete(payload.metadata.uid);
277
- break;
278
- }
279
- // Emit the data event
280
- this.#events.emit(WatchEvent.DATA, payload, phase);
281
- // Call the callback function with the parsed payload
282
- await this.#callback(payload, phase);
283
- }
284
- catch (err) {
285
- this.#events.emit(WatchEvent.DATA_ERROR, err);
311
+ // Call the callback function — if it throws, the cache is NOT updated
312
+ // so the next relist will detect the item as unprocessed and retry
313
+ await this.#callback(payload, phase);
314
+ // Only update cache after the callback succeeds
315
+ switch (phase) {
316
+ case WatchPhase.Added:
317
+ case WatchPhase.Modified:
318
+ this.#cache.set(payload.metadata.uid, payload);
319
+ break;
320
+ case WatchPhase.Deleted:
321
+ this.#cache.delete(payload.metadata.uid);
322
+ break;
286
323
  }
324
+ // Emit data event only after callback succeeds and cache is updated
325
+ this.#events.emit(WatchEvent.DATA, payload, phase);
287
326
  };
288
327
  // process a line from the chunk
289
328
  #processLine = async (line, process) => {
@@ -299,7 +338,9 @@ export class Watcher {
299
338
  message: this.#resourceVersion,
300
339
  };
301
340
  }
302
- // Process the event payload, do not update the resource version as that is handled by the list operation
341
+ // Process the event payload, do not update the resource version as that is handled by the list operation.
342
+ // Watch-stream events are not retried on callback failure — the relist timer
343
+ // will re-detect unprocessed items on its next cycle.
303
344
  await process(payload, phase);
304
345
  }
305
346
  catch (err) {
@@ -318,13 +359,18 @@ export class Watcher {
318
359
  */
319
360
  #watch = async (retryCount = 0) => {
320
361
  const maxRetries = 5;
321
- // Start with a list operation, but don't let it block the watch stream
362
+ // Start with a list operation
363
+ let listSucceeded = false;
322
364
  try {
323
- await this.#list();
365
+ listSucceeded = await this.#list();
324
366
  }
325
367
  catch (listError) {
326
368
  this.#events.emit(WatchEvent.LIST_ERROR, listError);
327
369
  }
370
+ // If the list failed, trigger a faster resync so items aren't permanently missed
371
+ if (!listSucceeded) {
372
+ this.#lastSeenTime = OVERRIDE;
373
+ }
328
374
  // Build the URL and request options
329
375
  try {
330
376
  const { opts, serverUrl } = await this.#buildURL(true, this.#resourceVersion);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kubernetes-fluent-client",
3
- "version": "3.11.6",
3
+ "version": "3.11.7",
4
4
  "description": "A @kubernetes/client-node fluent API wrapper that leverages K8s Server Side Apply.",
5
5
  "bin": "./dist/cli.js",
6
6
  "main": "dist/index.js",
@@ -56,12 +56,12 @@
56
56
  "node-fetch": "2.7.0",
57
57
  "quicktype-core": "23.2.6",
58
58
  "tsx": "4.21.0",
59
- "undici": "7.24.1",
59
+ "undici": "7.24.6",
60
60
  "yargs": "18.0.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@commitlint/cli": "20.4.3",
64
- "@commitlint/config-conventional": "20.4.3",
63
+ "@commitlint/cli": "20.5.0",
64
+ "@commitlint/config-conventional": "20.5.0",
65
65
  "@defenseunicorns/eslint-config": "1.2.0",
66
66
  "@eslint/eslintrc": "3.3.5",
67
67
  "@eslint/js": "10.0.1",
@@ -70,22 +70,23 @@
70
70
  "@types/urijs": "1.19.26",
71
71
  "@types/ws": "8.18.1",
72
72
  "@types/yargs": "17.0.35",
73
- "@typescript-eslint/eslint-plugin": "8.56.1",
74
- "@typescript-eslint/parser": "8.56.1",
75
- "@vitest/coverage-v8": "4.0.18",
76
- "command-line-args": "6.0.1",
77
- "eslint": "10.0.3",
78
- "eslint-plugin-jsdoc": "62.7.1",
79
- "globals": "17.4.0",
73
+ "@typescript-eslint/eslint-plugin": "8.58.2",
74
+ "@typescript-eslint/parser": "8.58.2",
75
+ "@vitest/coverage-v8": "4.1.4",
76
+ "command-line-args": "6.0.2",
77
+ "eslint": "10.2.0",
78
+ "eslint-plugin-jsdoc": "62.9.0",
79
+ "globals": "17.5.0",
80
80
  "husky": "9.1.7",
81
- "lint-staged": "16.3.2",
82
- "prettier": "3.8.1",
81
+ "lint-staged": "16.4.0",
82
+ "prettier": "3.8.3",
83
83
  "semantic-release": "25.0.3",
84
- "typescript": "5.9.3",
85
- "vitest": "4.0.18"
84
+ "typescript": "6.0.2",
85
+ "vitest": "4.1.4"
86
86
  },
87
87
  "overrides": {
88
88
  "tar-fs": "3.1.1",
89
+ "typescript-eslint": "8.58.0",
89
90
  "semantic-release@24.2.0": {
90
91
  "npm": {
91
92
  "glob": {
package/src/fetch.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors
3
3
 
4
4
  import { StatusCodes } from "http-status-codes";
5
- import { fetch as undiciFetch, RequestInfo, RequestInit } from "undici";
5
+ import { fetch as undiciFetch, Headers, RequestInfo, RequestInit } from "undici";
6
6
 
7
7
  export type FetchResponse<T> = {
8
8
  data: T;
@@ -57,7 +57,7 @@ export async function fetch<T>(
57
57
  data = (await resp.text()) as unknown as T;
58
58
  }
59
59
 
60
- return { data, ok, status, statusText, headers };
60
+ return { data, ok, status, statusText, headers: headers! };
61
61
  } catch (e) {
62
62
  // Always treat a catch as a failure for callers — even when the HTTP
63
63
  // transport returned 2xx, a body-parse error means `data` is unusable.
@@ -12,7 +12,7 @@ import {
12
12
  WatchAction,
13
13
  WatchPhase,
14
14
  } from "./shared-types.js";
15
- import { getHeaders, k8sCfg, pathBuilder, sleep, startSleep } from "./utils.js";
15
+ import { getHeaders, k8sCfg, pathBuilder, sleep } from "./utils.js";
16
16
 
17
17
  export enum WatchEvent {
18
18
  /** Watch is connected successfully */
@@ -96,6 +96,9 @@ export class Watcher<T extends GenericClass> {
96
96
  // Track if a reconnect is pending
97
97
  #pendingReconnect = false;
98
98
 
99
+ // Track if a list operation is in progress to prevent concurrent lists
100
+ #listInProgress = false;
101
+
99
102
  // The resource version to start the watch at, this will be updated after the list operation.
100
103
  #resourceVersion?: string;
101
104
 
@@ -138,7 +141,12 @@ export class Watcher<T extends GenericClass> {
138
141
  () => {
139
142
  this.#latestRelistWindow = new Date().toISOString();
140
143
  this.#events.emit(WatchEvent.INIT_CACHE_MISS, this.#latestRelistWindow);
141
- void this.#list();
144
+ void (async () => {
145
+ const success = await this.#list();
146
+ if (!success) {
147
+ this.#lastSeenTime = OVERRIDE;
148
+ }
149
+ })();
142
150
  },
143
151
  watchCfg.relistIntervalSec * 1000 + jitter,
144
152
  );
@@ -238,13 +246,23 @@ export class Watcher<T extends GenericClass> {
238
246
  * @param continueToken - the continue token for the list
239
247
  * @param removedItems - the list of items that have been removed
240
248
  * @param retryCount - current retry attempt count
249
+ * @param pageCount - number of continuation pages already fetched (0-based)
241
250
  * @returns resolves when list processing is complete
242
251
  */
243
252
  #list = async (
244
253
  continueToken?: string,
245
254
  removedItems?: Map<string, InstanceType<T>>,
246
255
  retryCount = 0,
247
- ): Promise<void> => {
256
+ pageCount = 0,
257
+ ): Promise<boolean> => {
258
+ const isTopLevel = !continueToken && !removedItems && retryCount === 0;
259
+ if (isTopLevel) {
260
+ if (this.#listInProgress) {
261
+ return false;
262
+ }
263
+ this.#listInProgress = true;
264
+ }
265
+
248
266
  const maxRetries = 5;
249
267
  const maxPages = 10;
250
268
 
@@ -266,13 +284,11 @@ export class Watcher<T extends GenericClass> {
266
284
  const retryAfterHeader = response.headers.get("retry-after");
267
285
  // Retry with exponential backoff if under retry limit to prevent infinite recursion if the server is returning errors
268
286
  if (retryCount < maxRetries && retryAfterHeader) {
269
- const backoffTime = retryAfterHeader
270
- ? parseInt(retryAfterHeader) * 1000
271
- : Math.min(startSleep * Math.pow(2, retryCount), 30000);
287
+ const backoffTime = parseInt(retryAfterHeader) * 1000;
272
288
 
273
289
  await sleep(backoffTime);
274
290
  try {
275
- return this.#list(continueToken, removedItems, retryCount + 1);
291
+ return await this.#list(continueToken, removedItems, retryCount + 1, pageCount);
276
292
  } catch (e) {
277
293
  this.#events.emit(
278
294
  WatchEvent.LIST_ERROR,
@@ -281,7 +297,7 @@ export class Watcher<T extends GenericClass> {
281
297
  }
282
298
  }
283
299
 
284
- return;
300
+ return false;
285
301
  }
286
302
 
287
303
  // Unconditionally assign the continue token from the response so that it is cleared on the last page
@@ -296,6 +312,8 @@ export class Watcher<T extends GenericClass> {
296
312
  // If removed items are not provided, clone the cache
297
313
  removedItems = removedItems || new Map(this.#cache.entries());
298
314
 
315
+ let anyCallbackFailed = false;
316
+
299
317
  // Process each item in the list
300
318
  for (const item of list.items || []) {
301
319
  const { uid } = item.metadata;
@@ -306,8 +324,14 @@ export class Watcher<T extends GenericClass> {
306
324
  // If the item does not exist, it is new and should be added
307
325
  if (!alreadyExists) {
308
326
  this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
309
- // Send added event. Use void here because we don't care about the result (no consequences here if it fails)
310
- void this.#process(item, WatchPhase.Added);
327
+ try {
328
+ // Keep lastSeenTime fresh to prevent spurious resync during slow list processing
329
+ this.#lastSeenTime = Date.now();
330
+ await this.#process(item, WatchPhase.Added);
331
+ } catch (err) {
332
+ anyCallbackFailed = true;
333
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
334
+ }
311
335
  continue;
312
336
  }
313
337
 
@@ -318,33 +342,54 @@ export class Watcher<T extends GenericClass> {
318
342
  // Check if the resource version is newer than the cached version
319
343
  if (itemRV > cachedRV) {
320
344
  this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
321
- // Send a modified event if the resource version has changed
322
- void this.#process(item, WatchPhase.Modified);
345
+ try {
346
+ this.#lastSeenTime = Date.now();
347
+ await this.#process(item, WatchPhase.Modified);
348
+ } catch (err) {
349
+ anyCallbackFailed = true;
350
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
351
+ }
323
352
  }
324
353
  }
325
354
 
326
355
  // If there is a continue token, call the list function again with the same removed items
327
356
  if (continueToken) {
328
357
  // Safeguard against infinite pagination
329
- if (retryCount >= maxPages) {
358
+ if (pageCount >= maxPages) {
330
359
  this.#events.emit(
331
360
  WatchEvent.LIST_ERROR,
332
361
  new Error(`Maximum pagination limit (${maxPages}) reached, stopping list operation`),
333
362
  );
334
- return;
363
+ return false;
335
364
  }
336
365
 
337
- // Continue pagination (not a retry, so reset retryCount to 0)
338
- await this.#list(continueToken, removedItems, 0);
366
+ // Continue pagination (not a retry, so reset retryCount to 0).
367
+ // Combine with current page's failure state so a callback failure on
368
+ // an earlier page is not masked by a successful later page.
369
+ const nextPageSucceeded = await this.#list(continueToken, removedItems, 0, pageCount + 1);
370
+ return !anyCallbackFailed && nextPageSucceeded;
339
371
  } else {
340
372
  // Otherwise, process the removed items
341
373
  for (const item of removedItems.values()) {
342
374
  this.#events.emit(WatchEvent.CACHE_MISS, this.#latestRelistWindow);
343
- void this.#process(item, WatchPhase.Deleted);
375
+ try {
376
+ this.#lastSeenTime = Date.now();
377
+ await this.#process(item, WatchPhase.Deleted);
378
+ } catch (err) {
379
+ anyCallbackFailed = true;
380
+ this.#events.emit(WatchEvent.DATA_ERROR, err);
381
+ }
344
382
  }
345
383
  }
384
+
385
+ return !anyCallbackFailed;
346
386
  } catch (err) {
347
387
  this.#events.emit(WatchEvent.LIST_ERROR, err);
388
+ return false;
389
+ } finally {
390
+ if (isTopLevel) {
391
+ this.#listInProgress = false;
392
+ }
348
393
  }
349
394
  };
350
395
 
@@ -355,28 +400,24 @@ export class Watcher<T extends GenericClass> {
355
400
  * @param phase - the event phase
356
401
  */
357
402
  #process = async (payload: InstanceType<T>, phase: WatchPhase) => {
358
- try {
359
- switch (phase) {
360
- // If the event is added or modified, update the cache
361
- case WatchPhase.Added:
362
- case WatchPhase.Modified:
363
- this.#cache.set(payload.metadata.uid, payload);
364
- break;
365
-
366
- // If the event is deleted, remove the item from the cache
367
- case WatchPhase.Deleted:
368
- this.#cache.delete(payload.metadata.uid);
369
- break;
370
- }
371
-
372
- // Emit the data event
373
- this.#events.emit(WatchEvent.DATA, payload, phase);
403
+ // Call the callback function — if it throws, the cache is NOT updated
404
+ // so the next relist will detect the item as unprocessed and retry
405
+ await this.#callback(payload, phase);
406
+
407
+ // Only update cache after the callback succeeds
408
+ switch (phase) {
409
+ case WatchPhase.Added:
410
+ case WatchPhase.Modified:
411
+ this.#cache.set(payload.metadata.uid, payload);
412
+ break;
374
413
 
375
- // Call the callback function with the parsed payload
376
- await this.#callback(payload, phase);
377
- } catch (err) {
378
- this.#events.emit(WatchEvent.DATA_ERROR, err);
414
+ case WatchPhase.Deleted:
415
+ this.#cache.delete(payload.metadata.uid);
416
+ break;
379
417
  }
418
+
419
+ // Emit data event only after callback succeeds and cache is updated
420
+ this.#events.emit(WatchEvent.DATA, payload, phase);
380
421
  };
381
422
 
382
423
  // process a line from the chunk
@@ -402,7 +443,9 @@ export class Watcher<T extends GenericClass> {
402
443
  };
403
444
  }
404
445
 
405
- // Process the event payload, do not update the resource version as that is handled by the list operation
446
+ // Process the event payload, do not update the resource version as that is handled by the list operation.
447
+ // Watch-stream events are not retried on callback failure — the relist timer
448
+ // will re-detect unprocessed items on its next cycle.
406
449
  await process(payload, phase);
407
450
  } catch (err) {
408
451
  if (err.name === "TooOld") {
@@ -422,13 +465,19 @@ export class Watcher<T extends GenericClass> {
422
465
  */
423
466
  #watch = async (retryCount = 0): Promise<void> => {
424
467
  const maxRetries = 5;
425
- // Start with a list operation, but don't let it block the watch stream
468
+ // Start with a list operation
469
+ let listSucceeded = false;
426
470
  try {
427
- await this.#list();
471
+ listSucceeded = await this.#list();
428
472
  } catch (listError) {
429
473
  this.#events.emit(WatchEvent.LIST_ERROR, listError);
430
474
  }
431
475
 
476
+ // If the list failed, trigger a faster resync so items aren't permanently missed
477
+ if (!listSucceeded) {
478
+ this.#lastSeenTime = OVERRIDE;
479
+ }
480
+
432
481
  // Build the URL and request options
433
482
  try {
434
483
  const { opts, serverUrl } = await this.#buildURL(true, this.#resourceVersion);