import { Logger } from '@dunx/core'; import { FetchError, FetchTransportError, HttpService, } from '@dunx/http/client'; import { HealthClient } from './health.client.js'; import { UpstreamPolicy } from './upstream.policy.js'; /** * Calling out over `fetch`. Three things a bare `fetch` does not do: retry a 503 * with backoff, raise a non-2xx as an error carrying the parsed body, and forward * the inbound trace. */ export class UpstreamDemo { constructor( private readonly logger: Logger, private readonly http: HttpService, // A named client as a constructor parameter, which is what registering it as // a subclass buys: `inject(httpClient('health'))` in a field is the only way // to reach one bound to a `Token`. private readonly health: HealthClient, private readonly policy: UpstreamPolicy, ) {} async demonstrate(url: string): Promise { const notes = await this.http.get( new URL('api/notes', url), ); this.logger.info(`GET api/notes -> ${JSON.stringify(notes)}`); const live = await this.health.get<{ status: string }>( new URL('api/health/live', url), ); this.logger.info(`HealthClient -> ${live.status}`); const attempts: string[] = []; const recovered = await this.http.get<{ after: number }>( new URL('api/upstream/flaky', url), { retry: { maxRetries: 3, retryDelayMs: 20, onAttempt: (attempt, isRetry) => attempts.push(`${attempt}${isRetry ? ' (retry)' : ''}`), }, }, ); this.logger.info( `two 503s then a 200 -> attempts ${attempts.join(', ')}, ` + `recovered after ${recovered.after}`, ); /** A 404 is a `FetchError`, not an `HttpError`: an upstream 401 arriving as * `HttpError(401)` would make this service answer 401 to its own caller. */ try { await this.http.get(new URL('api/upstream/missing', url), { retry: { maxRetries: 0 }, }); } catch (error) { if (!(error instanceof FetchError)) throw error; this.logger.info( `404 -> FetchError status ${error.status}, body ` + `${JSON.stringify(error.body)} (an AppError, so it surfaces as a 500 ` + 'rather than passing the upstream status through)', ); } // Not retried: an abort means the caller's signal or the timeout fired. try { await this.http.get(new URL('api/upstream/slow', url), { timeoutMs: 25, retry: { maxRetries: 0 }, }); throw new Error( 'the 25 ms budget should not have covered a 300 ms route', ); } catch (error) { if (!(error instanceof FetchTransportError)) throw error; this.logger.info( `timeoutMs: 25 against a 300 ms route -> ${error.name} ` + '(an abort is never retried)', ); } await this.demonstratePolicy(url); } /** `ResiliencePolicy` around the same upstream: the retry loop is core's, the * verdict on a status is `HttpRetryClassifier`'s, and every `run` passes the * attempt's `signal` on, which is the only thing the budget travels through. */ private async demonstratePolicy(url: string): Promise { const key = `policy-${Date.now()}`; const recovered = await this.policy.run((signal) => this.http.get<{ after: number }>( new URL(`api/upstream/flaky?key=${key}`, url), { retry: { maxRetries: 0 }, signal }, ), ); this.logger.info( `ResiliencePolicy retried the 503s itself -> recovered after ` + `${recovered.after}`, ); const answered = await this.policy.run((signal) => this.http.get<{ cached?: boolean }>( new URL('api/upstream/missing', url), { retry: { maxRetries: 0 }, signal }, ), ); this.logger.info( `ResiliencePolicy on a 404 -> cached=${answered.cached} ` + '(the classifier refuses to retry it, so the fallback answers)', ); const bounded = await this.policy.run((signal) => this.http.get<{ done?: true; cached?: boolean }>( new URL('api/upstream/slow', url), // `timeoutMs: 0`, so the only budget left is the policy's own. { retry: { maxRetries: 0 }, timeoutMs: 0, signal }, ), ); if (bounded.done === true) { throw new Error('the policy budget should have cancelled a 300 ms route'); } this.logger.info( `ResiliencePolicy timeoutMs against a 300 ms route -> ` + `cached=${bounded.cached} (the attempt signal reached fetch)`, ); } }