@loonylabs/tti-middleware 1.7.0 → 1.9.0

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.
@@ -102,10 +102,20 @@ export declare abstract class BaseTTIProvider implements ITTIProvider {
102
102
  */
103
103
  protected sleep(ms: number): Promise<void>;
104
104
  /**
105
- * Wrap an operation with a timeout. If the operation doesn't resolve
106
- * within timeoutMs, the returned promise rejects with a timeout error.
107
- * The original operation continues running (promises can't be cancelled),
108
- * but its result is ignored.
105
+ * Wrap an operation with a timeout and optional grace period.
106
+ *
107
+ * Normal flow (graceMs = 0):
108
+ * If the operation doesn't resolve within `timeoutMs`, the returned promise
109
+ * rejects immediately with a timeout error.
110
+ *
111
+ * Grace period flow (graceMs > 0):
112
+ * When `timeoutMs` fires, instead of rejecting immediately, a grace period
113
+ * starts. If the operation resolves successfully within `graceMs`, the result
114
+ * is used and no timeout error is thrown. Only if the grace period also expires
115
+ * does the promise reject — with an error reflecting the total wait time.
116
+ *
117
+ * This prevents paying for (and discarding) a Vertex AI response that arrived
118
+ * slightly after the timeout threshold.
109
119
  */
110
120
  private withTimeout;
111
121
  /**
@@ -249,6 +249,7 @@ class BaseTTIProvider {
249
249
  jitter: retryOption.jitter ?? types_1.DEFAULT_RETRY_OPTIONS.jitter,
250
250
  timeoutMs: retryOption.timeoutMs ?? types_1.DEFAULT_RETRY_OPTIONS.timeoutMs,
251
251
  timeoutRetries: retryOption.timeoutRetries ?? types_1.DEFAULT_RETRY_OPTIONS.timeoutRetries,
252
+ graceMs: retryOption.graceMs ?? types_1.DEFAULT_RETRY_OPTIONS.graceMs,
252
253
  };
253
254
  }
254
255
  /**
@@ -274,25 +275,68 @@ class BaseTTIProvider {
274
275
  return new Promise((resolve) => setTimeout(resolve, ms));
275
276
  }
276
277
  /**
277
- * Wrap an operation with a timeout. If the operation doesn't resolve
278
- * within timeoutMs, the returned promise rejects with a timeout error.
279
- * The original operation continues running (promises can't be cancelled),
280
- * but its result is ignored.
278
+ * Wrap an operation with a timeout and optional grace period.
279
+ *
280
+ * Normal flow (graceMs = 0):
281
+ * If the operation doesn't resolve within `timeoutMs`, the returned promise
282
+ * rejects immediately with a timeout error.
283
+ *
284
+ * Grace period flow (graceMs > 0):
285
+ * When `timeoutMs` fires, instead of rejecting immediately, a grace period
286
+ * starts. If the operation resolves successfully within `graceMs`, the result
287
+ * is used and no timeout error is thrown. Only if the grace period also expires
288
+ * does the promise reject — with an error reflecting the total wait time.
289
+ *
290
+ * This prevents paying for (and discarding) a Vertex AI response that arrived
291
+ * slightly after the timeout threshold.
281
292
  */
282
- withTimeout(operation, timeoutMs, operationName) {
293
+ withTimeout(operation, timeoutMs, graceMs, operationName) {
283
294
  return new Promise((resolve, reject) => {
284
- const timer = setTimeout(() => {
285
- reject(new Error(`timeout: ${operationName} did not complete within ${timeoutMs}ms`));
286
- }, timeoutMs);
287
- operation()
288
- .then((result) => {
289
- clearTimeout(timer);
295
+ let settled = false;
296
+ let inGracePeriod = false;
297
+ let mainTimerRef;
298
+ let graceTimerRef = null;
299
+ const operationPromise = operation();
300
+ // Handle operation resolution — can fire at any point, including during grace
301
+ operationPromise.then((result) => {
302
+ if (settled)
303
+ return;
304
+ settled = true;
305
+ clearTimeout(mainTimerRef);
306
+ if (graceTimerRef)
307
+ clearTimeout(graceTimerRef);
308
+ if (inGracePeriod) {
309
+ this.log('info', `${operationName} completed during grace period`, { graceMs });
310
+ }
290
311
  resolve(result);
291
- })
292
- .catch((error) => {
293
- clearTimeout(timer);
312
+ }, (error) => {
313
+ if (settled)
314
+ return;
315
+ settled = true;
316
+ clearTimeout(mainTimerRef);
317
+ if (graceTimerRef)
318
+ clearTimeout(graceTimerRef);
294
319
  reject(error);
295
320
  });
321
+ // Primary timeout
322
+ mainTimerRef = setTimeout(() => {
323
+ if (settled)
324
+ return;
325
+ if (graceMs > 0) {
326
+ inGracePeriod = true;
327
+ this.log('warn', `${operationName} primary timeout after ${timeoutMs}ms, entering grace period (${graceMs}ms)`, { timeoutMs, graceMs });
328
+ graceTimerRef = setTimeout(() => {
329
+ if (settled)
330
+ return;
331
+ settled = true;
332
+ reject(new Error(`timeout: ${operationName} did not complete within ${timeoutMs + graceMs}ms (including ${graceMs}ms grace period)`));
333
+ }, graceMs);
334
+ }
335
+ else {
336
+ settled = true;
337
+ reject(new Error(`timeout: ${operationName} did not complete within ${timeoutMs}ms`));
338
+ }
339
+ }, timeoutMs);
296
340
  });
297
341
  }
298
342
  /**
@@ -336,6 +380,7 @@ class BaseTTIProvider {
336
380
  return operation();
337
381
  }
338
382
  const timeoutMs = retryConfig.timeoutMs || 0;
383
+ const graceMs = retryConfig.graceMs ?? 0;
339
384
  const maxTimeoutRetries = retryConfig.timeoutRetries ?? 2;
340
385
  let lastError = null;
341
386
  let generalRetryCount = 0;
@@ -354,7 +399,7 @@ class BaseTTIProvider {
354
399
  });
355
400
  // Wrap with timeout if configured
356
401
  const result = timeoutMs > 0
357
- ? await this.withTimeout(operation, timeoutMs, operationName)
402
+ ? await this.withTimeout(operation, timeoutMs, graceMs, operationName)
358
403
  : await operation();
359
404
  const duration = Date.now() - attemptStart;
360
405
  this.log('info', `${operationName} completed in ${duration}ms`, {
@@ -450,7 +495,8 @@ class BaseTTIProvider {
450
495
  message.includes('epipe') ||
451
496
  message.includes('ehostunreach') ||
452
497
  message.includes('enetunreach') ||
453
- message.includes('socket hang up')) {
498
+ message.includes('socket hang up') ||
499
+ message.includes('fetch failed')) {
454
500
  return true;
455
501
  }
456
502
  return false;
@@ -482,7 +528,8 @@ class BaseTTIProvider {
482
528
  }
483
529
  if (errorMessage.includes('timeout') ||
484
530
  errorMessage.includes('econnrefused') ||
485
- errorMessage.includes('enotfound')) {
531
+ errorMessage.includes('enotfound') ||
532
+ errorMessage.includes('fetch failed')) {
486
533
  return new NetworkError(this.providerName, `Network error${context ? `: ${context}` : ''}`, error);
487
534
  }
488
535
  return new GenerationFailedError(this.providerName, `Generation failed${context ? `: ${context}` : ''}: ${error.message}`, error);
@@ -235,6 +235,23 @@ export interface RetryOptions {
235
235
  * long waits, while still allowing many retries for quota errors.
236
236
  */
237
237
  timeoutRetries?: number;
238
+ /**
239
+ * Grace period in milliseconds after a timeout before abandoning the attempt (default: 0).
240
+ *
241
+ * When `timeoutMs` fires, instead of immediately failing and starting a new retry,
242
+ * the middleware waits an additional `graceMs`. If the in-flight request resolves
243
+ * successfully within this window, the result is used and no retry is needed.
244
+ *
245
+ * This prevents paying for (and discarding) a successful response that arrived
246
+ * slightly after the timeout threshold — which is common under quota pressure when
247
+ * Vertex AI eventually returns a valid result after a long backoff.
248
+ *
249
+ * Example: `timeoutMs: 210000, graceMs: 60000`
250
+ * → waits up to 210s normally, then up to 60s more to capture a late success.
251
+ *
252
+ * Set to 0 (default) to disable — timeout retries fire immediately as before.
253
+ */
254
+ graceMs?: number;
238
255
  /**
239
256
  * @deprecated Use `backoffMultiplier` instead. Will be removed in v2.0.
240
257
  * When true, equivalent to backoffMultiplier of 1.0 with linear scaling (delayMs * attempt).
@@ -39,4 +39,5 @@ exports.DEFAULT_RETRY_OPTIONS = {
39
39
  jitter: true,
40
40
  timeoutMs: 45000,
41
41
  timeoutRetries: 2,
42
+ graceMs: 0,
42
43
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loonylabs/tti-middleware",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Provider-agnostic Text-to-Image middleware with GDPR compliance. Supports Google Cloud (Imagen, Gemini), Eden AI, and IONOS.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",