mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user