import { parseErrorResponse, HarmonicApiError } from './utils/errors.js';
import {
API_BASE_URL,
RATE_LIMIT_PER_SECOND,
MAX_RATE_LIMIT_RETRIES,
CHARACTER_LIMIT,
REQUEST_TIMEOUT_MS
} from './constants.js';
import type { ResponseFormat } from './schemas/inputs.js';
interface RateLimitInfo {
remaining: number;
resetAt: Date;
}
interface PaginatedResponse<T> {
count?: number;
page_info?: {
next?: string;
has_next?: boolean;
};
results: T[];
}
interface FormattedPaginatedResponse<T> {
data: T[];
count: number;
totalAvailable?: number;
hasMore: boolean;
nextCursor: string | null;
summary: string;
truncated?: boolean;
truncationMessage?: string;
}
/**
* Harmonic API client with authentication, rate limiting, and error handling
*/
export class HarmonicClient {
private apiKey: string;
private rateLimitInfo: RateLimitInfo = {
remaining: RATE_LIMIT_PER_SECOND,
resetAt: new Date()
};
private lastRequestTime: number = 0;
constructor(apiKey: string) {
if (!apiKey) {
throw new Error('HARMONIC_API_KEY is required');
}
this.apiKey = apiKey;
}
/**
* Implement basic rate limiting (10 req/sec)
*/
private async throttle(): Promise<void> {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequestTime;
const minInterval = 1000 / RATE_LIMIT_PER_SECOND; // 100ms between requests
if (timeSinceLastRequest < minInterval) {
await this.sleep(minInterval - timeSinceLastRequest);
}
this.lastRequestTime = Date.now();
}
/**
* Make authenticated request to Harmonic API with retry support and timeout
*/
async fetch<T>(
path: string,
options: RequestInit = {},
retryCount: number = 0
): Promise<T> {
await this.throttle();
const url = `${API_BASE_URL}${path}`;
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'apikey': this.apiKey,
'accept': 'application/json',
'Content-Type': 'application/json',
...options.headers
}
});
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms. The Harmonic API may be slow or unavailable.`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
// Update rate limit info from headers
this.updateRateLimitInfo(response);
if (!response.ok) {
const error = await parseErrorResponse(response);
// Handle rate limiting with retry (with max attempts)
if (response.status === 429 && retryCount < MAX_RATE_LIMIT_RETRIES) {
const waitMs = this.getRetryWaitTime();
if (waitMs > 0 && waitMs < 10000) {
await this.sleep(waitMs);
return this.fetch<T>(path, options, retryCount + 1);
}
}
throw error;
}
return response.json() as Promise<T>;
}
/**
* GET request helper
*/
async get<T>(path: string, params?: Record<string, string | string[] | number | number[] | boolean | undefined>): Promise<T> {
const searchParams = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
value.forEach(v => searchParams.append(key, String(v)));
} else {
searchParams.set(key, String(value));
}
}
}
const queryString = searchParams.toString();
const fullPath = queryString ? `${path}?${queryString}` : path;
return this.fetch<T>(fullPath);
}
/**
* POST request helper (used for lookup/enrich operations - these are READ operations in Harmonic API)
*/
async post<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
// Harmonic uses query params for POST lookup endpoints
const searchParams = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
searchParams.set(key, value);
}
}
const queryString = searchParams.toString();
const fullPath = queryString ? `${path}?${queryString}` : path;
return this.fetch<T>(fullPath, { method: 'POST' });
}
/**
* POST request with JSON body (used for batch operations like /companies/batchGet)
*/
async postJson<T>(path: string, body: unknown): Promise<T> {
return this.fetch<T>(path, {
method: 'POST',
body: JSON.stringify(body)
});
}
/**
* Update rate limit tracking from response headers
*/
private updateRateLimitInfo(response: Response): void {
const remaining = response.headers.get('X-Ratelimit-Remaining-Second')
|| response.headers.get('x-ratelimit-remaining-second');
if (remaining) {
this.rateLimitInfo.remaining = parseInt(remaining, 10);
}
}
/**
* Calculate wait time for rate limit retry
*/
private getRetryWaitTime(): number {
// Wait 1 second for rate limit reset
return 1000;
}
/**
* Sleep helper
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Get current rate limit status (for debugging/monitoring)
*/
getRateLimitStatus(): RateLimitInfo {
return { ...this.rateLimitInfo };
}
}
// Singleton client instance
let clientInstance: HarmonicClient | null = null;
/**
* Get or create the Harmonic client instance
*/
export function getClient(): HarmonicClient {
if (!clientInstance) {
const apiKey = process.env.HARMONIC_API_KEY;
if (!apiKey) {
throw new Error('HARMONIC_API_KEY environment variable is not set');
}
clientInstance = new HarmonicClient(apiKey);
}
return clientInstance;
}
/**
* Extract pagination cursor from Harmonic response
*/
export function extractCursor<T>(response: PaginatedResponse<T>): string | null {
return response.page_info?.next || null;
}
/**
* Extract numeric ID from Harmonic URN
* @example "urn:harmonic:company:18920281" -> "18920281"
*/
export function extractIdFromUrn(urn: string): string {
const match = urn.match(/urn:harmonic:\w+:(\d+)/);
if (match) {
return match[1];
}
// If it's already a numeric ID, return as-is
if (/^\d+$/.test(urn)) {
return urn;
}
throw new Error(`Invalid URN or ID format: ${urn}`);
}
/**
* Format paginated response for tool output with truncation support
*/
export function formatPaginatedResponse<T>(
results: T[],
nextCursor: string | null,
totalCount: number | undefined,
itemLabel: string = 'items'
): FormattedPaginatedResponse<T> {
const response: FormattedPaginatedResponse<T> = {
data: results,
count: results.length,
totalAvailable: totalCount,
hasMore: !!nextCursor,
nextCursor,
summary: totalCount
? `Found ${results.length} of ${totalCount} ${itemLabel}${nextCursor ? ' (more available with cursor)' : ''}`
: `Found ${results.length} ${itemLabel}${nextCursor ? ' (more available with cursor)' : ''}`
};
// Check if response exceeds character limit
const serialized = JSON.stringify(response, null, 2);
if (serialized.length > CHARACTER_LIMIT) {
// Truncate data array to fit within limit
const truncatedData = results.slice(0, Math.max(1, Math.floor(results.length / 2)));
response.data = truncatedData;
response.count = truncatedData.length;
response.truncated = true;
response.truncationMessage = `Response truncated from ${results.length} to ${truncatedData.length} ${itemLabel}. Use 'cursor' parameter or reduce 'size' to see more results.`;
response.summary = `Found ${truncatedData.length} ${itemLabel} (truncated from ${results.length})${nextCursor ? ' - more available with cursor' : ''}`;
}
return response;
}
/**
* Format paginated response as markdown
*/
export function formatPaginatedMarkdown<T>(
results: T[],
nextCursor: string | null,
totalCount: number | undefined,
entityName: string,
formatItem: (item: T, index: number) => string
): string {
const lines: string[] = [];
lines.push(`# ${entityName}`);
lines.push('');
if (totalCount) {
lines.push(`Found **${results.length}** of **${totalCount}** ${entityName.toLowerCase()}${nextCursor ? ' (more available)' : ''}`);
} else {
lines.push(`Found **${results.length}** ${entityName.toLowerCase()}${nextCursor ? ' (more available)' : ''}`);
}
lines.push('');
for (let i = 0; i < results.length; i++) {
lines.push(formatItem(results[i], i));
lines.push('');
}
if (nextCursor) {
lines.push('---');
lines.push(`*More results available. Use cursor: \`${nextCursor}\`*`);
}
let result = lines.join('\n');
// Check character limit
if (result.length > CHARACTER_LIMIT) {
const halfData = results.slice(0, Math.max(1, Math.floor(results.length / 2)));
const truncatedLines: string[] = [];
truncatedLines.push(`# ${entityName}`);
truncatedLines.push('');
truncatedLines.push(`**Showing ${halfData.length} of ${results.length} ${entityName.toLowerCase()}** (truncated)`);
truncatedLines.push('');
for (let i = 0; i < halfData.length; i++) {
truncatedLines.push(formatItem(halfData[i], i));
truncatedLines.push('');
}
truncatedLines.push('---');
truncatedLines.push(`*Response truncated. Use cursor or filters to see more results.*`);
result = truncatedLines.join('\n');
}
return result;
}
/**
* Format a single entity as markdown
*/
export function formatEntityMarkdown(
title: string,
sections: Array<{ heading?: string; content: string }>
): string {
const lines: string[] = [];
lines.push(`# ${title}`);
lines.push('');
for (const section of sections) {
if (section.heading) {
lines.push(`## ${section.heading}`);
lines.push('');
}
lines.push(section.content);
lines.push('');
}
return lines.join('\n');
}
/**
* Format response based on requested format
*/
export function formatResponse<T>(
data: T,
format: ResponseFormat
): string {
if (format === 'markdown' && typeof data === 'string') {
return data;
}
return JSON.stringify(data, null, 2);
}