@rhofkens/mcp-quotes-server-claude-code
Version:
Model Context Protocol (MCP) server for managing and serving quotes
277 lines • 10.6 kB
JavaScript
/**
* Resilient Serper API Client
*
* Enhanced version of SerperClient with resilience patterns:
* - Circuit breaker for fault tolerance
* - Retry logic with exponential backoff
* - Response caching with fallback
* - Health monitoring
*/
import { quoteCache } from '../utils/cache.js';
import { createCircuitBreaker } from '../utils/circuitBreaker.js';
import { APIError, ErrorCode } from '../utils/errors.js';
import { healthCheckManager, createSerperHealthCheck, createCircuitBreakerHealthCheck, createCacheHealthCheck, } from '../utils/healthCheck.js';
import { logger } from '../utils/logger.js';
import { createRetryWrapper } from '../utils/retry.js';
import { SerperClient } from './serperClient.js';
/**
* Resilient Serper API Client
*/
export class ResilientSerperClient extends SerperClient {
apiCircuitBreaker;
cache;
retryWrapper;
resilientConfig;
constructor(config) {
super(config);
this.resilientConfig = {
enableCache: true,
enableCircuitBreaker: true,
enableRetry: true,
cacheTTL: 10 * 60 * 1000, // 10 minutes
maxRetries: 3,
apiKey: config?.apiKey || '',
baseUrl: config?.baseUrl || 'https://google.serper.dev',
timeout: config?.timeout || 30000,
};
// Initialize cache
this.cache = quoteCache;
// Initialize circuit breaker
this.apiCircuitBreaker = createCircuitBreaker('serper-api', {
failureThreshold: 5,
successThreshold: 2,
timeout: 60000, // 1 minute
monitoringPeriod: 5 * 60 * 1000, // 5 minutes
fallbackFunction: () => this.getCachedFallback(),
healthCheckFunction: async () => {
try {
// Quick health check search
await super.searchQuotes({ query: 'test', num: 1 });
return true;
}
catch {
return false;
}
},
});
// Initialize retry wrapper
this.retryWrapper = createRetryWrapper({
maxAttempts: this.resilientConfig.maxRetries || 3,
initialDelay: 1000,
maxDelay: 10000,
backoffFactor: 2,
jitter: true,
...(this.resilientConfig.enableCircuitBreaker && { circuitBreaker: this.apiCircuitBreaker }),
onRetry: (error, attempt) => {
logger.warn('Retrying Serper API call', {
attempt,
error: error instanceof Error ? error.message : String(error),
});
},
});
// Register health checks
this.registerHealthChecks();
}
/**
* Enhanced search with resilience patterns
*/
async searchQuotes(params) {
const cacheKey = this.generateCacheKey(params);
// Try cache first if enabled
if (this.resilientConfig.enableCache) {
const cached = this.cache.get(cacheKey);
if (cached) {
logger.debug('Cache hit for search', { query: params.query });
// Transform cached quotes back to search results
return this.quotesToSearchResults(cached);
}
}
try {
// Execute search with resilience patterns
const searchFunction = () => super.searchQuotes(params);
let results;
if (this.resilientConfig.enableRetry) {
results = await this.retryWrapper(searchFunction);
}
else if (this.resilientConfig.enableCircuitBreaker) {
results = await this.apiCircuitBreaker.execute(searchFunction);
}
else {
results = await searchFunction();
}
// Cache successful results
if (this.resilientConfig.enableCache && results.length > 0) {
const quotes = this.searchResultsToQuotes(results, params.query);
this.cache.set(cacheKey, quotes, this.resilientConfig.cacheTTL);
logger.debug('Cached search results', {
query: params.query,
count: results.length,
});
}
return results;
}
catch (error) {
logger.error('Search failed, attempting fallback', {
query: params.query,
error: error instanceof Error ? error.message : String(error),
});
// Try stale cache as last resort
if (this.resilientConfig.enableCache) {
const { data, stale } = this.cache.getWithFallback(cacheKey);
if (data) {
logger.warn('Using stale cache data', {
query: params.query,
stale,
});
return this.quotesToSearchResults(data);
}
}
throw error;
}
}
/**
* Build enhanced quote search with caching awareness
*/
buildQuoteSearchQuery(person, topic) {
// Use parent implementation
const query = super.buildQuoteSearchQuery(person, topic);
// Pre-warm cache for common variations if this is a new search
if (this.resilientConfig.enableCache) {
this.prewarmCache(person, topic);
}
return query;
}
/**
* Get system health status
*/
async getHealthStatus() {
return {
circuitBreaker: this.apiCircuitBreaker.getStats(),
cache: this.cache.getStats(),
config: {
cacheEnabled: this.resilientConfig.enableCache,
circuitBreakerEnabled: this.resilientConfig.enableCircuitBreaker,
retryEnabled: this.resilientConfig.enableRetry,
},
};
}
/**
* Reset all resilience components
*/
reset() {
this.apiCircuitBreaker.reset();
this.cache.clear();
this.cache.resetStats();
logger.info('Resilient client reset');
}
/**
* Generate cache key for search params
*/
generateCacheKey(params) {
return `serper:${params.query}:${params.num || 10}`;
}
/**
* Convert search results to quotes for caching
*/
searchResultsToQuotes(results, query) {
const quotes = [];
for (const result of results) {
const quoteText = this.extractQuoteFromSnippet(result.snippet);
if (quoteText) {
// Extract person name from query
const personMatch = query.match(/"([^"]+)"/);
const person = personMatch?.[1] || 'Unknown';
quotes.push({
text: quoteText,
author: person,
...(result.link && { source: result.link }),
});
}
}
return quotes;
}
/**
* Convert cached quotes back to search results
*/
quotesToSearchResults(quotes) {
return quotes.map((quote) => ({
title: `${quote.author} Quote`,
link: quote.source || '',
snippet: `"${quote.text}"`,
position: 0,
}));
}
/**
* Get cached fallback data
*/
getCachedFallback() {
logger.warn('Circuit breaker open, searching cache for any fallback data');
// Try to find any cached data that might be relevant
const cacheKeys = this.cache.keys();
if (cacheKeys.length > 0) {
// Return the most recently accessed cache entry
for (const key of cacheKeys) {
const { data } = this.cache.getWithFallback(key);
if (data && data.length > 0) {
logger.info('Found fallback cache data', { key });
return this.quotesToSearchResults(data);
}
}
}
// No cache available, return empty array
logger.error('No fallback cache data available');
throw new APIError('Service unavailable and no cached data available', ErrorCode.API_ERROR, 'serper', { circuitBreakerOpen: true });
}
/**
* Pre-warm cache with related searches
*/
async prewarmCache(person, topic) {
// This is a background operation, don't await
setImmediate(async () => {
try {
// If searching with a topic, also cache without topic
if (topic) {
const genericKey = `serper:"${person}" quotes:10`;
if (!this.cache.has(genericKey)) {
logger.debug('Pre-warming cache for generic search', { person });
// Don't use resilient search here to avoid recursion
const results = await super.searchQuotes({
query: `"${person}" quotes`,
num: 10,
});
if (results.length > 0) {
const quotes = this.searchResultsToQuotes(results, `"${person}" quotes`);
this.cache.set(genericKey, quotes);
}
}
}
}
catch (error) {
// Pre-warming is best effort, don't propagate errors
logger.debug('Cache pre-warming failed', {
error: error instanceof Error ? error.message : String(error),
});
}
});
}
/**
* Register health checks
*/
registerHealthChecks() {
// Circuit breaker health check
if (this.resilientConfig.enableCircuitBreaker) {
healthCheckManager.register('serper-circuit-breaker', createCircuitBreakerHealthCheck('serper-api', () => this.apiCircuitBreaker.getStats()));
}
// Cache health check
if (this.resilientConfig.enableCache) {
healthCheckManager.register('quote-cache', createCacheHealthCheck(() => this.cache.getStats()));
}
// API health check
if (this.resilientConfig.apiKey) {
healthCheckManager.register('serper-api', createSerperHealthCheck(this.resilientConfig.apiKey, this.resilientConfig.baseUrl));
}
}
}
// Export singleton instance with default configuration
export const resilientSerperClient = new ResilientSerperClient();
//# sourceMappingURL=resilientSerperClient.js.map