From a0dc2f798caa7c33ed0ab2b17b79ef4da7e61fdb Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 2 Feb 2026 13:31:47 -0600 Subject: [PATCH] fix(#196, #199): Fix TypeScript errors from race condition and throttler changes - Regenerated Prisma client to include version field from #196 - Updated ThrottlerValkeyStorageService to match @nestjs/throttler v6.5 interface - increment() now returns ThrottlerStorageRecord with totalHits, timeToExpire, isBlocked - Added blockDuration and throttlerName parameters to match interface - Added null checks for job variable after length checks in coordinator-integration.service.ts - Fixed template literal type error in ConcurrentUpdateException - Removed unnecessary await in throttler-storage.service.ts - Fixes pipeline 79 typecheck failure Co-Authored-By: Claude Sonnet 4.5 --- .../exceptions/concurrent-update.exception.ts | 2 +- .../throttler/throttler-storage.service.ts | 49 ++++++++++++++++--- .../coordinator-integration.service.ts | 15 ++++-- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/apps/api/src/common/exceptions/concurrent-update.exception.ts b/apps/api/src/common/exceptions/concurrent-update.exception.ts index 9cd2212..04dccb1 100644 --- a/apps/api/src/common/exceptions/concurrent-update.exception.ts +++ b/apps/api/src/common/exceptions/concurrent-update.exception.ts @@ -8,7 +8,7 @@ import { ConflictException } from "@nestjs/common"; export class ConcurrentUpdateException extends ConflictException { constructor(resourceType: string, resourceId: string, currentVersion?: number) { const message = currentVersion - ? `Concurrent update detected for ${resourceType} ${resourceId} at version ${currentVersion}. The record was modified by another process.` + ? `Concurrent update detected for ${resourceType} ${resourceId} at version ${String(currentVersion)}. The record was modified by another process.` : `Concurrent update detected for ${resourceType} ${resourceId}. The record was modified by another process.`; super({ diff --git a/apps/api/src/common/throttler/throttler-storage.service.ts b/apps/api/src/common/throttler/throttler-storage.service.ts index d64c9ab..1977b03 100644 --- a/apps/api/src/common/throttler/throttler-storage.service.ts +++ b/apps/api/src/common/throttler/throttler-storage.service.ts @@ -1,7 +1,18 @@ import { Injectable, OnModuleInit, Logger } from "@nestjs/common"; -import { ThrottlerStorageService } from "@nestjs/throttler"; +import { ThrottlerStorage } from "@nestjs/throttler"; import Redis from "ioredis"; +/** + * Throttler storage record interface + * Matches @nestjs/throttler's ThrottlerStorageRecord + */ +interface ThrottlerStorageRecord { + totalHits: number; + timeToExpire: number; + isBlocked: boolean; + timeToBlockExpire: number; +} + /** * Redis-based storage for rate limiting using Valkey * @@ -12,9 +23,9 @@ import Redis from "ioredis"; * If Redis is unavailable, falls back to in-memory storage. */ @Injectable() -export class ThrottlerValkeyStorageService implements ThrottlerStorageService, OnModuleInit { +export class ThrottlerValkeyStorageService implements ThrottlerStorage, OnModuleInit { private readonly logger = new Logger(ThrottlerValkeyStorageService.name); - private client?: Redis; + private client: Redis | undefined = undefined; private readonly THROTTLER_PREFIX = "mosaic:throttler:"; private readonly fallbackStorage = new Map(); private useRedis = false; @@ -54,27 +65,49 @@ export class ThrottlerValkeyStorageService implements ThrottlerStorageService, O * * @param key - Throttle key (e.g., "apikey:xxx" or "ip:192.168.1.1") * @param ttl - Time to live in milliseconds - * @returns Promise resolving to the current number of requests + * @param limit - Maximum number of requests allowed + * @param blockDuration - Duration to block in milliseconds (not used in this implementation) + * @param _throttlerName - Name of the throttler (not used in this implementation) + * @returns Promise resolving to the current throttler storage record */ - async increment(key: string, ttl: number): Promise { + async increment( + key: string, + ttl: number, + limit: number, + blockDuration: number, + _throttlerName: string + ): Promise { const throttleKey = this.getThrottleKey(key); + let totalHits: number; if (this.useRedis && this.client) { try { const result = await this.client.multi().incr(throttleKey).pexpire(throttleKey, ttl).exec(); if (result?.[0]?.[1]) { - return result[0][1] as number; + totalHits = result[0][1] as number; + } else { + totalHits = this.incrementMemory(throttleKey, ttl); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`Redis increment failed: ${errorMessage}`); // Fall through to in-memory + totalHits = this.incrementMemory(throttleKey, ttl); } + } else { + // In-memory fallback + totalHits = this.incrementMemory(throttleKey, ttl); } - // In-memory fallback - return this.incrementMemory(throttleKey, ttl); + // Return ThrottlerStorageRecord + const isBlocked = totalHits > limit; + return { + totalHits, + timeToExpire: ttl, + isBlocked, + timeToBlockExpire: isBlocked ? blockDuration : 0, + }; } /** diff --git a/apps/api/src/coordinator-integration/coordinator-integration.service.ts b/apps/api/src/coordinator-integration/coordinator-integration.service.ts index 82809f0..f58c372 100644 --- a/apps/api/src/coordinator-integration/coordinator-integration.service.ts +++ b/apps/api/src/coordinator-integration/coordinator-integration.service.ts @@ -120,11 +120,14 @@ export class CoordinatorIntegrationService { FOR UPDATE `; - if (!jobs || jobs.length === 0) { + if (jobs.length === 0) { throw new NotFoundException(`RunnerJob with ID ${jobId} not found`); } const job = jobs[0]; + if (!job) { + throw new NotFoundException(`RunnerJob with ID ${jobId} not found`); + } // Validate status transition if (!this.isValidStatusTransition(job.status, dto.status as RunnerJobStatus)) { @@ -245,11 +248,14 @@ export class CoordinatorIntegrationService { FOR UPDATE `; - if (!jobs || jobs.length === 0) { + if (jobs.length === 0) { throw new NotFoundException(`RunnerJob with ID ${jobId} not found`); } const job = jobs[0]; + if (!job) { + throw new NotFoundException(`RunnerJob with ID ${jobId} not found`); + } // Validate status transition if (!this.isValidStatusTransition(job.status, RunnerJobStatus.COMPLETED)) { @@ -312,11 +318,14 @@ export class CoordinatorIntegrationService { FOR UPDATE `; - if (!jobs || jobs.length === 0) { + if (jobs.length === 0) { throw new NotFoundException(`RunnerJob with ID ${jobId} not found`); } const job = jobs[0]; + if (!job) { + throw new NotFoundException(`RunnerJob with ID ${jobId} not found`); + } // Validate status transition if (!this.isValidStatusTransition(job.status, RunnerJobStatus.FAILED)) {