import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { isAxiosError } from 'axios'; import { firstValueFrom } from 'rxjs'; import { z } from 'zod'; import { parseHiboutik } from '../models/hiboutikDTO/hiboutik.schema'; /** Results per page of every paginated Hiboutik endpoint (see "Results per page" in the API docs). */ const HIBOUTIK_PAGE_SIZE = 250; /** Retries of a request answered 429 (too many requests), with exponential backoff: 2 s, 4 s, 8 s. */ const RATE_LIMIT_RETRIES = 3; const RATE_LIMIT_BASE_DELAY_MS = 2_000; /** Cap of the wait asked by a `Retry-After` header: beyond, waiting holds the sync for nothing (monthly quota). */ const RATE_LIMIT_MAX_DELAY_MS = 60_000; /** * Hiboutik still answers 429 after the retries: the rate limit (30 concurrent calls) or the monthly quota (10,000 * calls per shop) is reached. The synchronization must stop: every next call would fail the same way. */ export class HiboutikRateLimitError extends Error { constructor(path: string) { super(`Hiboutik rate limit reached on ${path} (still 429 after ${RATE_LIMIT_RETRIES} retries): monthly quota exhausted?`); this.name = 'HiboutikRateLimitError'; } } /** Rethrows a {@link HiboutikRateLimitError}: for the per-item `catch` blocks of the sync, which must not swallow it. */ export function rethrowIfRateLimited(error: unknown): void { if (error instanceof HiboutikRateLimitError) throw error; } /** Wait asked by a `Retry-After` header (seconds or HTTP date), if any. */ function retryAfterMs(header: unknown): number | undefined { if (typeof header !== 'string' || header.trim() === '') return undefined; const seconds = Number(header); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); const date = Date.parse(header); return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); } /** * Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. * Every answer is validated against a zod schema (see `hiboutik.schema.ts`): a malformed one throws, like an HTTP error. */ @Injectable() export class HiboutikClient { private readonly baseUrl: string; private readonly authHeader: string; constructor( private readonly httpService: HttpService, private readonly configService: ConfigService, ) { const username = this.configService.get('HIBOUTIK_USERNAME') ?? ''; const password = this.configService.get('HIBOUTIK_PASSWORD') ?? ''; this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; this.baseUrl = this.configService.get('HIBOUTIK_BASE_URL') ?? ''; } /** * GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the raw response body. A 429 is retried with * backoff (or after the `Retry-After` delay), then turns into a {@link HiboutikRateLimitError}. */ private async get(path: string, params?: Record): Promise { for (let attempt = 0; ; attempt++) { try { const response = await firstValueFrom( this.httpService.get(`${this.baseUrl}${path}`, { headers: { Authorization: this.authHeader }, params, }), ); return response.data; } catch (error) { if (!isAxiosError(error) || error.response?.status !== 429) throw error; if (attempt >= RATE_LIMIT_RETRIES) throw new HiboutikRateLimitError(path); const delay = retryAfterMs(error.response.headers['retry-after']) ?? RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt; await this.sleep(Math.min(delay, RATE_LIMIT_MAX_DELAY_MS)); } } } /** Separate method so that tests can skip the waits. */ protected sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * GET on a list endpoint. HTTP errors (4xx/5xx) throw, a 2xx answer without body is an empty list, and any other * non-list body or invalid item throws: an empty list triggers deletions in the sync, so it must never be inferred * from a malformed answer. */ async getList(path: string, itemSchema: T, params?: Record): Promise[]> { const data = await this.get(path, params); if (data === '' || data == null) return []; if (!Array.isArray(data)) throw new Error(`Unexpected answer from Hiboutik ${path}: a list was expected`); return parseHiboutik(z.array(itemSchema), data, path); } /** * Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results. * Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results. */ async getAllPages(path: string, itemSchema: T, baseParams: Record = {}): Promise[]> { return (await this.walkPages(path, itemSchema, baseParams)).items; } /** * Same as {@link getAllPages}, for a list whose content drives deletions. An item created or deleted in Hiboutik while * the pages are walked shifts the following pages, so an item can be skipped. When the list spans several pages, it * is walked a second time and both walks are merged by id (the second one wins): an item is missing from the result * only if both walks missed it. A list that fits in one page costs no extra call. */ async getAllPagesForSync( path: string, itemSchema: T, idOf: (item: z.output) => number, baseParams: Record = {}, ): Promise[]> { const firstWalk = await this.walkPages(path, itemSchema, baseParams); if (firstWalk.nbPages <= 1) return firstWalk.items; const secondWalk = await this.walkPages(path, itemSchema, baseParams); const byId = new Map>(); for (const item of [...firstWalk.items, ...secondWalk.items]) byId.set(idOf(item), item); return [...byId.values()]; } /** Walks the pages of a paginated endpoint. `nbPages` counts the pages holding results. */ private async walkPages( path: string, itemSchema: T, baseParams: Record, ): Promise<{ items: z.output[]; nbPages: number }> { const items: z.output[] = []; let page = 1; while (true) { const pageData = await this.getList(path, itemSchema, { ...baseParams, p: page }); if (pageData.length === 0) break; items.push(...pageData); // A page smaller than the page size is the last one: no need to request an empty page if (pageData.length < HIBOUTIK_PAGE_SIZE) break; page++; } return { items, nbPages: Math.ceil(items.length / HIBOUTIK_PAGE_SIZE) }; } /** * GET on a single-resource endpoint (Hiboutik answers with a one-element array). * @returns `null` when Hiboutik says the resource does not exist: a 404, or a 2xx answer that is empty. * @throws on any other HTTP error or malformed answer: an unknown state is never taken for a missing resource. */ async getOne(path: string, schema: T): Promise | null> { try { const [item] = await this.getList(path, schema); return item ?? null; } catch (error) { if (isAxiosError(error) && error.response?.status === 404) return null; throw error; } } }