feat: add per-provider throttle backoff for metadata fetch

- detect HTTP 429 via fetchWithThrottle utility and throw ProviderThrottleError
- track per-provider cooldown windows with exponential backoff in ProviderThrottleTracker
- Google Books uses a conservative schedule (10m/30m/1h/2h/4h); others use default (2m/5m/10m/30m/1h)
- pipeline filters throttled providers before each book fetch
- orchestrator extends inter-book delay to 10-20s when any provider is throttled
This commit is contained in:
neon
2026-03-18 09:09:32 -06:00
parent d07b876b9c
commit c836eedca9
13 changed files with 137 additions and 16 deletions
@@ -5,7 +5,7 @@ defineProps<{ bookId: number }>()
</script>
<template>
<div class="flex flex-col my-2 -ml-2 min-h-[calc(100%-1.5rem)] overflow-hidden rounded-xl border border-border/70 bg-card/60">
<div class="flex flex-col my-2 min-h-[calc(100%-1.5rem)] overflow-hidden rounded-xl border border-border/70 bg-card/60">
<BookDetailHeader :book-id="bookId" />
<main class="flex-1 overflow-y-auto overflow-x-hidden px-6 py-6">
<slot />
@@ -6,6 +6,7 @@ import * as schema from '../../db/schema';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { MetadataService } from '../metadata/metadata.service';
import { MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
import { ProviderThrottleTracker } from '../metadata-fetch/provider-throttle.tracker';
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
import { BookMetadataFetchConfigService } from './book-metadata-fetch-config.service';
import { BookMetadataFetchEligibilityService } from './book-metadata-fetch-eligibility.service';
@@ -32,6 +33,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
private readonly metadataService: MetadataService,
private readonly scoreService: MetadataScoreService,
private readonly session: BookMetadataFetchSessionService,
private readonly throttleTracker: ProviderThrottleTracker,
@Optional() private readonly gateway?: BookMetadataFetchGateway,
) {}
@@ -310,7 +312,11 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
}
private randomDelay(): Promise<void> {
const ms = 2_000 + Math.random() * 3_000;
const throttleActive = this.throttleTracker.hasAnyActive();
const ms = throttleActive ? 10_000 + Math.random() * 10_000 : 2_000 + Math.random() * 3_000;
if (throttleActive) {
this.logger.debug(`throttle active - extending inter-book delay to ${Math.round(ms)}ms`);
}
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -0,0 +1,11 @@
import { ProviderThrottleError } from './provider-throttle.error';
export async function fetchWithThrottle(url: string | URL, options?: RequestInit): Promise<Response> {
const res = await fetch(url, options);
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After');
const retryAfterSeconds = retryAfter ? Number(retryAfter) : undefined;
throw new ProviderThrottleError(Number.isFinite(retryAfterSeconds) && retryAfterSeconds! > 0 ? retryAfterSeconds : undefined);
}
return res;
}
@@ -15,6 +15,7 @@ import { ProviderConfigService } from '../metadata-preferences/provider-config.s
import { MetadataFetchPipeline } from './metadata-fetch-pipeline';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
import { ProviderThrottleTracker } from './provider-throttle.tracker';
function createPreferences(mutate?: (fields: Record<MetadataField, FieldPreference>) => void): MetadataFetchPreferences {
const fields = Object.fromEntries(
@@ -72,7 +73,8 @@ describe('MetadataFetchPipeline', () => {
all: vi.fn(),
} as unknown as Mocked<ProviderRegistry>;
pipeline = new MetadataFetchPipeline(fetchService, preferencesService, resolver, providerConfigService, registry);
const throttleTracker = { isThrottled: vi.fn().mockReturnValue(false) } as unknown as ProviderThrottleTracker;
pipeline = new MetadataFetchPipeline(fetchService, preferencesService, resolver, providerConfigService, registry, throttleTracker);
});
it('derives enabled provider keys from active fields, filters unknown providers, and de-duplicates keys', async () => {
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { FieldPreference, MetadataCandidate, MetadataFetchPreferences, MetadataField, MetadataProviderKey } from '@projectx/types';
import { firstValueFrom, toArray } from 'rxjs';
@@ -7,6 +7,7 @@ import { MetadataPreferencesService } from '../metadata-preferences/metadata-pre
import { ProviderConfigService } from '../metadata-preferences/provider-config.service';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
import { ProviderThrottleTracker } from './provider-throttle.tracker';
import { MetadataSearchParams } from './providers/metadata-search-params';
export type ResolvedMetadataFields = Partial<Record<MetadataField, string | string[] | number | null>> & { coverUrl?: string };
@@ -14,12 +15,15 @@ type ResolvedProviderIds = Partial<Record<MetadataProviderKey, string>>;
@Injectable()
export class MetadataFetchPipeline {
private readonly logger = new Logger(MetadataFetchPipeline.name);
constructor(
private readonly fetchService: MetadataFetchService,
private readonly preferencesService: MetadataPreferencesService,
private readonly resolver: MetadataPreferenceResolver,
private readonly providerConfigService: ProviderConfigService,
private readonly registry: ProviderRegistry,
private readonly throttleTracker: ProviderThrottleTracker,
) {}
async run(
@@ -79,7 +83,16 @@ export class MetadataFetchPipeline {
if (field === 'genres' && mergeGenresFromAllConfigured) continue;
fp.providers.filter((k) => registered.has(k)).forEach((k) => keys.add(k));
}
return [...keys];
const active: MetadataProviderKey[] = [];
for (const key of keys) {
if (this.throttleTracker.isThrottled(key)) {
this.logger.debug(`[${key}] skipped - currently throttled`);
} else {
active.push(key);
}
}
return active;
}
private async getEnabledConfiguredProviders(registered: Set<MetadataProviderKey>): Promise<MetadataProviderKey[]> {
@@ -7,6 +7,7 @@ import { MetadataFetchPipeline } from './metadata-fetch-pipeline';
import { MetadataProvider } from './providers/metadata-provider';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
import { ProviderThrottleTracker } from './provider-throttle.tracker';
import { AmazonProvider } from './providers/amazon/amazon.provider';
import { GoogleProvider } from './providers/google/google.provider';
import { GoodreadsProvider } from './providers/goodreads/goodreads.provider';
@@ -25,10 +26,11 @@ const PROVIDER_CLASSES = [GoogleProvider, GoodreadsProvider, AmazonProvider, Ope
inject: PROVIDER_CLASSES,
},
ProviderRegistry,
ProviderThrottleTracker,
MetadataFetchService,
MetadataFetchPipeline,
],
controllers: [MetadataFetchController],
exports: [MetadataFetchService, MetadataFetchPipeline, ProviderRegistry],
exports: [MetadataFetchService, MetadataFetchPipeline, ProviderRegistry, ProviderThrottleTracker],
})
export class MetadataFetchModule {}
@@ -4,6 +4,7 @@ import { firstValueFrom, toArray } from 'rxjs';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
import { ProviderThrottleTracker } from './provider-throttle.tracker';
import { IdentifiableProvider, MetadataProvider } from './providers/metadata-provider';
type DbMock = {
@@ -38,7 +39,8 @@ describe('MetadataFetchService', () => {
},
};
service = new MetadataFetchService(registry, db as never);
const throttleTracker = { clearOnSuccess: vi.fn(), record: vi.fn() } as unknown as ProviderThrottleTracker;
service = new MetadataFetchService(registry, throttleTracker, db as never);
});
afterEach(() => {
@@ -7,6 +7,8 @@ import { from, merge, Observable, switchMap } from 'rxjs';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata } from '../../db/schema';
import { ProviderThrottleError } from './provider-throttle.error';
import { ProviderThrottleTracker } from './provider-throttle.tracker';
import { ProviderRegistry } from './provider-registry';
import { isIdentifiable, MetadataProvider } from './providers/metadata-provider';
import { MetadataSearchParams } from './providers/metadata-search-params';
@@ -20,12 +22,13 @@ export class MetadataFetchService {
constructor(
private readonly registry: ProviderRegistry,
private readonly throttleTracker: ProviderThrottleTracker,
@Inject(DB) private readonly db: Db,
) {}
search(params: MetadataSearchParams, keys?: MetadataProviderKey[]): Observable<MetadataCandidate> {
const providers = this.registry.select(keys);
return merge(...providers.map((p) => from(this.withTimeout(this.fetchFromProvider(p, params))).pipe(switchMap((results) => from(results)))));
return merge(...providers.map((p) => from(this.fetchFromProviderWithThrottleHandling(p, params)).pipe(switchMap((results) => from(results)))));
}
async lookupById(key: MetadataProviderKey, providerId: string): Promise<MetadataCandidate | null> {
@@ -57,6 +60,21 @@ export class MetadataFetchService {
};
}
private async fetchFromProviderWithThrottleHandling(p: MetadataProvider, params: MetadataSearchParams): Promise<MetadataCandidate[]> {
try {
const results = await this.withTimeout(this.fetchFromProvider(p, params));
this.throttleTracker.clearOnSuccess(p.key);
return results;
} catch (err) {
if (err instanceof ProviderThrottleError) {
const hint = err.retryAfterSeconds != null ? `${err.retryAfterSeconds}s (Retry-After header)` : 'scheduled backoff';
this.logger.warn(`[${p.key}] throttled by provider - ${hint}`);
this.throttleTracker.record(p.key, err.retryAfterSeconds);
}
return [];
}
}
private async fetchFromProvider(p: MetadataProvider, params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const existingId = params.existingProviderIds?.[p.key];
if (isIdentifiable(p) && existingId) {
@@ -90,7 +108,13 @@ export class MetadataFetchService {
const timeout = new Promise<MetadataCandidate[]>((resolve) => {
timer = setTimeout(() => resolve([]), MetadataFetchService.PROVIDER_TIMEOUT_MS);
});
return Promise.race([promise.catch(() => [] as MetadataCandidate[]), timeout]).finally(() => clearTimeout(timer!));
return Promise.race([
promise.catch((err: unknown) => {
if (err instanceof ProviderThrottleError) throw err;
return [] as MetadataCandidate[];
}),
timeout,
]).finally(() => clearTimeout(timer!));
}
}
@@ -0,0 +1,6 @@
export class ProviderThrottleError extends Error {
constructor(readonly retryAfterSeconds?: number) {
super('Provider throttled (HTTP 429)');
this.name = 'ProviderThrottleError';
}
}
@@ -0,0 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { MetadataProviderKey } from '@projectx/types';
const DEFAULT_SCHEDULE = [120, 300, 600, 1800, 3600];
const PROVIDER_SCHEDULES: Partial<Record<MetadataProviderKey, number[]>> = {
// Google Books free tier is 1,000 req/day — throttles aggressively on batch fetches
[MetadataProviderKey.GOOGLE]: [600, 1800, 3600, 7200, 14400],
};
function scheduleFor(key: MetadataProviderKey): number[] {
return PROVIDER_SCHEDULES[key] ?? DEFAULT_SCHEDULE;
}
@Injectable()
export class ProviderThrottleTracker {
private readonly logger = new Logger(ProviderThrottleTracker.name);
private readonly throttledUntil = new Map<MetadataProviderKey, number>();
private readonly backoffLevel = new Map<MetadataProviderKey, number>();
record(key: MetadataProviderKey, retryAfterSeconds?: number): void {
const level = this.backoffLevel.get(key) ?? 0;
const schedule = scheduleFor(key);
const seconds = retryAfterSeconds ?? schedule[Math.min(level, schedule.length - 1)];
const until = Date.now() + seconds * 1000;
this.throttledUntil.set(key, until);
this.backoffLevel.set(key, level + 1);
this.logger.log(`[${key}] cooldown set until ${new Date(until).toISOString()} (${seconds}s, level ${level + 1})`);
}
isThrottled(key: MetadataProviderKey): boolean {
const until = this.throttledUntil.get(key);
return until !== undefined && Date.now() < until;
}
clearOnSuccess(key: MetadataProviderKey): void {
const wasThrottled = this.throttledUntil.has(key);
this.throttledUntil.delete(key);
this.backoffLevel.delete(key);
if (wasThrottled) {
this.logger.log(`[${key}] throttle cleared after successful fetch`);
}
}
hasAnyActive(): boolean {
const now = Date.now();
for (const until of this.throttledUntil.values()) {
if (now < until) return true;
}
return false;
}
}
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { mapGoogleVolume } from './google.mapper';
@@ -31,7 +32,7 @@ export class GoogleProvider implements IdentifiableProvider {
const { enabled, apiKey } = await this.providerConfig.getConfig().then((c) => c.google);
if (!enabled) return null;
const url = this.buildUrl(`/volumes/${providerId}`, {}, apiKey);
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const res = await fetchWithThrottle(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) {
this.logger.warn(`Google Books API returned ${res.status} for lookupById(${providerId})`);
return null;
@@ -50,7 +51,7 @@ export class GoogleProvider implements IdentifiableProvider {
private async fetchVolumes(query: string, apiKey: string): Promise<MetadataCandidate[]> {
const url = this.buildUrl('/volumes', { q: query, maxResults: '10', printType: 'books' }, apiKey);
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const res = await fetchWithThrottle(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) {
this.logger.warn(`Google Books API returned ${res.status} for search("${query}")`);
return [];
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { mapITunesResult } from './itunes.mapper';
@@ -33,7 +34,7 @@ export class ITunesProvider implements IdentifiableProvider {
url.searchParams.set('limit', '10');
try {
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(10_000) });
const res = await fetchWithThrottle(url.toString(), { signal: AbortSignal.timeout(10_000) });
if (!res.ok) {
this.logger.warn(`iTunes Search API returned ${res.status} for search("${query}")`);
return [];
@@ -54,7 +55,7 @@ export class ITunesProvider implements IdentifiableProvider {
url.searchParams.set('id', providerId);
try {
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(10_000) });
const res = await fetchWithThrottle(url.toString(), { signal: AbortSignal.timeout(10_000) });
if (!res.ok) {
this.logger.warn(`iTunes Lookup API returned ${res.status} for lookupById(${providerId})`);
return null;
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { mapOpenLibraryDoc, mapOpenLibraryWork } from './open-library.mapper';
@@ -27,7 +28,7 @@ export class OpenLibraryProvider implements IdentifiableProvider {
query.set('limit', '10');
query.set('fields', SEARCH_FIELDS);
const res = await fetch(`${BASE_URL}/search.json?${query}`, { signal: AbortSignal.timeout(10_000) });
const res = await fetchWithThrottle(`${BASE_URL}/search.json?${query}`, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return [];
const body = (await res.json()) as OpenLibrarySearchResponse;
@@ -37,7 +38,7 @@ export class OpenLibraryProvider implements IdentifiableProvider {
async lookupById(providerId: string): Promise<MetadataCandidate | null> {
const { enabled } = await this.providerConfig.getConfig().then((c) => c.openLibrary);
if (!enabled) return null;
const res = await fetch(`${BASE_URL}/works/${providerId}.json`, { signal: AbortSignal.timeout(10_000) });
const res = await fetchWithThrottle(`${BASE_URL}/works/${providerId}.json`, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const work = (await res.json()) as OpenLibraryWork;
@@ -67,7 +68,7 @@ export class OpenLibraryProvider implements IdentifiableProvider {
limit: '20',
fields: 'key,subject',
});
const res = await fetch(`${BASE_URL}/search.json?${query}`, { signal: AbortSignal.timeout(10_000) });
const res = await fetchWithThrottle(`${BASE_URL}/search.json?${query}`, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return undefined;
const body = (await res.json()) as Partial<OpenLibrarySearchResponse>;