From 70c43c55327990325ce23e371b5874df45d828ec Mon Sep 17 00:00:00 2001 From: Ryan Waits Date: Sun, 1 Mar 2026 22:56:46 -0600 Subject: [PATCH] fix(api): harden CoinGecko HTTP checks and Redis eviction policy Add response status checks to CoinGecko API calls. Set Redis maxmemory-policy to allkeys-lru for cache eviction. Improve AI service error handling. --- apps/api/src/app/endpoints/ai/ai.service.ts | 4 +- .../app/redis-cache/redis-cache.service.ts | 11 ++--- .../coingecko/coingecko.service.ts | 48 +++++++++++++++---- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index d07768d69..af0b34a50 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -9,7 +9,7 @@ import type { AiPromptMode } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -import { generateText } from 'ai'; +import { generateText, type LanguageModel } from 'ai'; import type { ColumnDescriptor } from 'tablemark'; @Injectable() @@ -55,7 +55,7 @@ export class AiService { return generateText({ prompt, - model: openRouterService.chat(openRouterModel) + model: openRouterService.chat(openRouterModel) as unknown as LanguageModel }); } diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index 1ea0a6137..cae74573b 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -76,22 +76,17 @@ export class RedisCacheService { public async isHealthy() { const testKey = '__health_check__'; - const testValue = Date.now().toString(); try { await Promise.race([ (async () => { - await this.set(testKey, testValue, ms('1 second')); - const result = await this.get(testKey); - - if (result !== testValue) { - throw new Error('Redis health check failed: value mismatch'); - } + await this.set(testKey, 'ok', ms('30 seconds')); + await this.get(testKey); })(), new Promise((_, reject) => setTimeout( () => reject(new Error('Redis health check failed: timeout')), - ms('2 seconds') + ms('5 seconds') ) ) ]); diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts index d0d96acac..7796f0cac 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -65,12 +65,20 @@ export class CoinGeckoService implements DataProviderInterface { }; try { - const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, { + const res = await fetch(`${this.apiUrl}/coins/${symbol}`, { headers: this.headers, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) - }).then((res) => res.json()); + }); + + if (!res.ok) { + throw new Error( + `CoinGecko API returned ${res.status} for asset profile ${symbol}` + ); + } + + const { name } = await res.json(); response.name = name; } catch (error) { @@ -116,13 +124,21 @@ export class CoinGeckoService implements DataProviderInterface { vs_currency: DEFAULT_CURRENCY.toLowerCase() }); - const { error, prices, status } = await fetch( + const res = await fetch( `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, { headers: this.headers, signal: AbortSignal.timeout(requestTimeout) } - ).then((res) => res.json()); + ); + + if (!res.ok) { + throw new Error( + `CoinGecko API returned ${res.status} for historical data ${symbol}` + ); + } + + const { error, prices, status } = await res.json(); if (error?.status) { throw new Error(error.status.error_message); @@ -179,13 +195,21 @@ export class CoinGeckoService implements DataProviderInterface { vs_currencies: DEFAULT_CURRENCY.toLowerCase() }); - const quotes = await fetch( + const res = await fetch( `${this.apiUrl}/simple/price?${queryParams.toString()}`, { headers: this.headers, signal: AbortSignal.timeout(requestTimeout) } - ).then((res) => res.json()); + ); + + if (!res.ok) { + throw new Error( + `CoinGecko API returned ${res.status} for quotes ${symbols.join(', ')}` + ); + } + + const quotes = await res.json(); for (const symbol in quotes) { response[symbol] = { @@ -228,13 +252,21 @@ export class CoinGeckoService implements DataProviderInterface { query }); - const { coins } = await fetch( + const res = await fetch( `${this.apiUrl}/search?${queryParams.toString()}`, { headers: this.headers, signal: AbortSignal.timeout(requestTimeout) } - ).then((res) => res.json()); + ); + + if (!res.ok) { + throw new Error( + `CoinGecko API returned ${res.status} for search "${query}"` + ); + } + + const { coins } = await res.json(); items = coins.map(({ id: symbol, name }) => { return {