Browse Source

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.
pull/6458/head
Ryan Waits 1 month ago
parent
commit
70c43c5532
  1. 4
      apps/api/src/app/endpoints/ai/ai.service.ts
  2. 11
      apps/api/src/app/redis-cache/redis-cache.service.ts
  3. 48
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts

4
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 { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai'; import { generateText, type LanguageModel } from 'ai';
import type { ColumnDescriptor } from 'tablemark'; import type { ColumnDescriptor } from 'tablemark';
@Injectable() @Injectable()
@ -55,7 +55,7 @@ export class AiService {
return generateText({ return generateText({
prompt, prompt,
model: openRouterService.chat(openRouterModel) model: openRouterService.chat(openRouterModel) as unknown as LanguageModel
}); });
} }

11
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -76,22 +76,17 @@ export class RedisCacheService {
public async isHealthy() { public async isHealthy() {
const testKey = '__health_check__'; const testKey = '__health_check__';
const testValue = Date.now().toString();
try { try {
await Promise.race([ await Promise.race([
(async () => { (async () => {
await this.set(testKey, testValue, ms('1 second')); await this.set(testKey, 'ok', ms('30 seconds'));
const result = await this.get(testKey); await this.get(testKey);
if (result !== testValue) {
throw new Error('Redis health check failed: value mismatch');
}
})(), })(),
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => reject(new Error('Redis health check failed: timeout')), () => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds') ms('5 seconds')
) )
) )
]); ]);

48
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -65,12 +65,20 @@ export class CoinGeckoService implements DataProviderInterface {
}; };
try { try {
const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, { const res = await fetch(`${this.apiUrl}/coins/${symbol}`, {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_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; response.name = name;
} catch (error) { } catch (error) {
@ -116,13 +124,21 @@ export class CoinGeckoService implements DataProviderInterface {
vs_currency: DEFAULT_CURRENCY.toLowerCase() vs_currency: DEFAULT_CURRENCY.toLowerCase()
}); });
const { error, prices, status } = await fetch( const res = await fetch(
`${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`,
{ {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) 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) { if (error?.status) {
throw new Error(error.status.error_message); throw new Error(error.status.error_message);
@ -179,13 +195,21 @@ export class CoinGeckoService implements DataProviderInterface {
vs_currencies: DEFAULT_CURRENCY.toLowerCase() vs_currencies: DEFAULT_CURRENCY.toLowerCase()
}); });
const quotes = await fetch( const res = await fetch(
`${this.apiUrl}/simple/price?${queryParams.toString()}`, `${this.apiUrl}/simple/price?${queryParams.toString()}`,
{ {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) 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) { for (const symbol in quotes) {
response[symbol] = { response[symbol] = {
@ -228,13 +252,21 @@ export class CoinGeckoService implements DataProviderInterface {
query query
}); });
const { coins } = await fetch( const res = await fetch(
`${this.apiUrl}/search?${queryParams.toString()}`, `${this.apiUrl}/search?${queryParams.toString()}`,
{ {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) 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 }) => { items = coins.map(({ id: symbol, name }) => {
return { return {

Loading…
Cancel
Save