/**
* Hacker News Algolia API Client
* API Documentation: https://hn.algolia.com/api
*/
import type {
HNComment,
HNItemResponse,
HNSearchResult,
HNStory,
HNUserResponse,
SearchParams,
} from '../types/hn-api.js';
import { HNAPIError, ItemNotFoundError, NetworkError } from './errors.js';
import { createChildLogger } from './logger.js';
import { rateLimiter } from './rate-limiter.js';
const BASE_URL = 'https://hn.algolia.com/api/v1';
const REQUEST_TIMEOUT = 30000; // 30 seconds
const MAX_RETRIES = 3;
export class HNClient {
private async fetchWithRetry(url: string, retries = MAX_RETRIES): Promise<Response> {
const correlationId = crypto.randomUUID();
const reqLogger = createChildLogger({ correlationId, url });
for (let attempt = 1; attempt <= retries; attempt++) {
try {
// Check rate limit before each request
rateLimiter.checkLimit();
reqLogger.debug({ attempt }, 'Sending request');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
const response = await fetch(url, {
signal: controller.signal,
});
clearTimeout(timeoutId);
reqLogger.info(
{
status: response.status,
statusText: response.statusText,
attempt,
},
'Request completed'
);
return response;
} catch (error) {
const isLastAttempt = attempt === retries;
if (error instanceof Error && error.name === 'AbortError') {
reqLogger.warn({ attempt }, 'Request timeout');
if (isLastAttempt) {
throw new NetworkError('Request timed out after 30 seconds', error, { url, correlationId });
}
} else {
reqLogger.error({ error, attempt }, 'Request failed');
if (isLastAttempt) {
throw new NetworkError('Network request failed', error as Error, { url, correlationId });
}
}
// Exponential backoff
const delay = Math.min(1000 * 2 ** (attempt - 1), 10000);
reqLogger.debug({ delay }, 'Retrying after delay');
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
// This should never be reached due to throwing in the loop
throw new NetworkError('Max retries exceeded', undefined, { url });
}
/** Search stories by relevance */
async search(params: SearchParams): Promise<HNSearchResult<HNStory>> {
const searchParams = new URLSearchParams();
searchParams.set('query', params.query);
if (params.tags) {
searchParams.set('tags', params.tags);
}
if (params.numericFilters) {
searchParams.set('numericFilters', params.numericFilters);
}
if (params.page !== undefined) {
searchParams.set('page', params.page.toString());
}
if (params.hitsPerPage !== undefined) {
searchParams.set('hitsPerPage', params.hitsPerPage.toString());
}
if (params.restrictSearchableAttributes) {
searchParams.set('restrictSearchableAttributes', params.restrictSearchableAttributes);
}
const url = `${BASE_URL}/search?${searchParams.toString()}`;
const response = await this.fetchWithRetry(url);
if (!response.ok) {
const text = await response.text();
throw new HNAPIError(`HN API error: ${response.statusText}`, response.status, text);
}
return (await response.json()) as HNSearchResult<HNStory>;
}
/** Search content by date (most recent first) */
async searchByDate(params: SearchParams): Promise<HNSearchResult<HNStory | HNComment>> {
const searchParams = new URLSearchParams();
searchParams.set('query', params.query);
if (params.tags) {
searchParams.set('tags', params.tags);
}
if (params.numericFilters) {
searchParams.set('numericFilters', params.numericFilters);
}
if (params.page !== undefined) {
searchParams.set('page', params.page.toString());
}
if (params.hitsPerPage !== undefined) {
searchParams.set('hitsPerPage', params.hitsPerPage.toString());
}
const url = `${BASE_URL}/search_by_date?${searchParams.toString()}`;
const response = await this.fetchWithRetry(url);
if (!response.ok) {
const text = await response.text();
throw new HNAPIError(`HN API error: ${response.statusText}`, response.status, text);
}
return (await response.json()) as HNSearchResult<HNStory | HNComment>;
}
/** Get specific item by ID with nested comments */
async getItem(id: number): Promise<HNItemResponse> {
const url = `${BASE_URL}/items/${id}`;
const response = await this.fetchWithRetry(url);
if (response.status === 404) {
throw new ItemNotFoundError('story', id);
}
if (!response.ok) {
const text = await response.text();
throw new HNAPIError(`HN API error: ${response.statusText}`, response.status, text);
}
return (await response.json()) as HNItemResponse;
}
/** Get user profile by username */
async getUser(username: string): Promise<HNUserResponse> {
const url = `${BASE_URL}/users/${username}`;
const response = await this.fetchWithRetry(url);
if (response.status === 404) {
throw new ItemNotFoundError('user', username);
}
if (!response.ok) {
const text = await response.text();
throw new HNAPIError(`HN API error: ${response.statusText}`, response.status, text);
}
return (await response.json()) as HNUserResponse;
}
}