diff --git a/apps/dashboard/lib/reconciliation/core-sync.ts b/apps/dashboard/lib/reconciliation/core-sync.ts index 9433247..42aa5a6 100644 --- a/apps/dashboard/lib/reconciliation/core-sync.ts +++ b/apps/dashboard/lib/reconciliation/core-sync.ts @@ -31,11 +31,29 @@ * deterministic event id, so a retried run never double-records. */ -import type { ActivityChange, ActivityEvent, GuildSnapshot, Membership } from "@guildpass/integration-client"; -import { CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION } from "@guildpass/integration-client"; +import type { + ActivityChange, + ActivityEvent, + GuildSnapshot, + Membership, +} from "@guildpass/integration-client"; +import { + CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION, + CircuitOpenError, + TimeoutError, + UpstreamError, +} from "@guildpass/integration-client"; import type { Guild, Member, Pass } from "../mock-data"; -import type { IGuildRepository, IMemberRepository, IPassRepository } from "../repositories/types"; -import { getGuildRepository, getMemberRepository, getPassRepository } from "../repositories/factory"; +import type { + IGuildRepository, + IMemberRepository, + IPassRepository, +} from "../repositories/types"; +import { + getGuildRepository, + getMemberRepository, + getPassRepository, +} from "../repositories/factory"; import { activityStorage, type IActivityStorage } from "../activity/storage"; import { publishActivityEvent } from "../activity/stream"; import type { @@ -46,7 +64,13 @@ import type { SnapshotClient, } from "./core-sync-types"; -export type { CoreSyncChange, CoreSyncDeps, CoreSyncMode, CoreSyncReport, SnapshotClient }; +export type { + CoreSyncChange, + CoreSyncDeps, + CoreSyncMode, + CoreSyncReport, + SnapshotClient, +}; /** Map core membership status to the dashboard's member vocabulary. */ function mapMemberStatus(status: Membership["status"]): Member["status"] { @@ -87,7 +111,58 @@ export async function reconcileGuildWithCore(options: { const publish = options.deps?.publish ?? publishActivityEvent; const now = options.deps?.now ?? (() => new Date().toISOString()); - const snapshot = await client.getGuildSnapshot(guildId); + let snapshot: GuildSnapshot | null | undefined; + + try { + snapshot = await client.getGuildSnapshot(guildId); + } catch (err: unknown) { + if (err instanceof CircuitOpenError) { + return { + guildId, + mode, + supported: false, + reason: + "GuildPass core is currently unreachable (circuit open). " + + "The circuit will allow a probe request after the cooldown period. " + + "Reconciliation is unavailable until the circuit closes.", + changes: [], + totals: { added: 0, updated: 0, deactivated: 0, unchanged: 0 }, + applied: 0, + summary: + "Reconciliation unavailable: circuit breaker is open (core is failing).", + }; + } + if (err instanceof TimeoutError) { + return { + guildId, + mode, + supported: false, + reason: + "GuildPass core timed out while fetching the guild snapshot. " + + "The core may be slow or overloaded. Reconciliation will be retried on the next run.", + changes: [], + totals: { added: 0, updated: 0, deactivated: 0, unchanged: 0 }, + applied: 0, + summary: "Reconciliation unavailable: core timed out.", + }; + } + if (err instanceof UpstreamError) { + return { + guildId, + mode, + supported: false, + reason: + `GuildPass core responded with status ${err.status} while fetching ` + + "the guild snapshot. Reconciliation cannot proceed until core recovers.", + changes: [], + totals: { added: 0, updated: 0, deactivated: 0, unchanged: 0 }, + applied: 0, + summary: `Reconciliation unavailable: core returned HTTP ${err.status}.`, + }; + } + // Re-throw unexpected errors + throw err; + } if (!snapshot) { return { @@ -101,7 +176,8 @@ export async function reconcileGuildWithCore(options: { changes: [], totals: { added: 0, updated: 0, deactivated: 0, unchanged: 0 }, applied: 0, - summary: "Reconciliation unavailable: core snapshot endpoint not supported.", + summary: + "Reconciliation unavailable: core snapshot endpoint not supported.", }; } @@ -111,7 +187,13 @@ export async function reconcileGuildWithCore(options: { passRepo.getAll(guildId), ]); - const changes = diffSnapshot(guildId, snapshot, localGuild, localMembers, localPasses); + const changes = diffSnapshot( + guildId, + snapshot, + localGuild, + localMembers, + localPasses, + ); const totals = { added: changes.filter((c) => c.action === "add").length, @@ -127,7 +209,15 @@ export async function reconcileGuildWithCore(options: { if (mode === "apply") { for (const change of changes) { await applyChange(guildId, change, { memberRepo, passRepo, guildRepo }); - const recorded = await recordChange(guildId, change, snapshot, mode, sink, publish, now); + const recorded = await recordChange( + guildId, + change, + snapshot, + mode, + sink, + publish, + now, + ); if (recorded) applied += 1; } } @@ -175,7 +265,11 @@ function diffSnapshot( id: key, summary: `Add member ${sm.userId} (${sm.wallet}) — present in core, missing locally`, changes: [ - { field: "status", before: undefined, after: mapMemberStatus(sm.status) }, + { + field: "status", + before: undefined, + after: mapMemberStatus(sm.status), + }, { field: "roles", before: undefined, after: sm.roles ?? [] }, ], snapshotMember: sm, @@ -186,17 +280,27 @@ function diffSnapshot( const fieldChanges: ActivityChange[] = []; const coreStatus = mapMemberStatus(sm.status); if (local.status !== coreStatus) { - fieldChanges.push({ field: "status", before: local.status, after: coreStatus }); + fieldChanges.push({ + field: "status", + before: local.status, + after: coreStatus, + }); } if (!sameRoles(local.roles ?? [], sm.roles ?? [])) { - fieldChanges.push({ field: "roles", before: local.roles, after: sm.roles ?? [] }); + fieldChanges.push({ + field: "roles", + before: local.roles, + after: sm.roles ?? [], + }); } if (fieldChanges.length > 0) { changes.push({ entity: "member", action: "update", id: local.id, - summary: `Update member ${local.name} — ${fieldChanges.map((c) => c.field).join(", ")} drifted from core`, + summary: `Update member ${local.name} — ${fieldChanges + .map((c) => c.field) + .join(", ")} drifted from core`, changes: fieldChanges, localMember: local, snapshotMember: sm, @@ -239,21 +343,45 @@ function diffSnapshot( } const fieldChanges: ActivityChange[] = []; - if (local.name !== sp.name) fieldChanges.push({ field: "name", before: local.name, after: sp.name }); - if (local.status !== sp.status) fieldChanges.push({ field: "status", before: local.status, after: sp.status }); - if (local.price !== sp.price) fieldChanges.push({ field: "price", before: local.price, after: sp.price }); + if (local.name !== sp.name) + fieldChanges.push({ field: "name", before: local.name, after: sp.name }); + if (local.status !== sp.status) + fieldChanges.push({ + field: "status", + before: local.status, + after: sp.status, + }); + if (local.price !== sp.price) + fieldChanges.push({ + field: "price", + before: local.price, + after: sp.price, + }); if ((local.maxSupply ?? null) !== (sp.maxSupply ?? null)) { - fieldChanges.push({ field: "maxSupply", before: local.maxSupply ?? null, after: sp.maxSupply ?? null }); + fieldChanges.push({ + field: "maxSupply", + before: local.maxSupply ?? null, + after: sp.maxSupply ?? null, + }); } - if (sp.currentSupply !== undefined && local.currentSupply !== sp.currentSupply) { - fieldChanges.push({ field: "currentSupply", before: local.currentSupply, after: sp.currentSupply }); + if ( + sp.currentSupply !== undefined && + local.currentSupply !== sp.currentSupply + ) { + fieldChanges.push({ + field: "currentSupply", + before: local.currentSupply, + after: sp.currentSupply, + }); } if (fieldChanges.length > 0) { changes.push({ entity: "pass", action: "update", id: local.id, - summary: `Update pass "${local.name}" — ${fieldChanges.map((c) => c.field).join(", ")} drifted from core`, + summary: `Update pass "${local.name}" — ${fieldChanges + .map((c) => c.field) + .join(", ")} drifted from core`, changes: fieldChanges, localPass: local, snapshotPass: sp, @@ -264,18 +392,34 @@ function diffSnapshot( // ── Guild metadata ────────────────────────────────────────────────────── if (localGuild && snapshot.guild) { const fieldChanges: ActivityChange[] = []; - if (snapshot.guild.name !== undefined && localGuild.name !== snapshot.guild.name) { - fieldChanges.push({ field: "name", before: localGuild.name, after: snapshot.guild.name }); + if ( + snapshot.guild.name !== undefined && + localGuild.name !== snapshot.guild.name + ) { + fieldChanges.push({ + field: "name", + before: localGuild.name, + after: snapshot.guild.name, + }); } - if (snapshot.guild.description !== undefined && localGuild.description !== snapshot.guild.description) { - fieldChanges.push({ field: "description", before: localGuild.description, after: snapshot.guild.description }); + if ( + snapshot.guild.description !== undefined && + localGuild.description !== snapshot.guild.description + ) { + fieldChanges.push({ + field: "description", + before: localGuild.description, + after: snapshot.guild.description, + }); } if (fieldChanges.length > 0) { changes.push({ entity: "guild", action: "update", id: guildId, - summary: `Update guild — ${fieldChanges.map((c) => c.field).join(", ")} drifted from core`, + summary: `Update guild — ${fieldChanges + .map((c) => c.field) + .join(", ")} drifted from core`, changes: fieldChanges, snapshotGuild: snapshot.guild, }); @@ -307,7 +451,11 @@ async function applyChange( joinedAt: sm.updatedAt, lastActive: sm.updatedAt, }); - } else if (change.action === "update" && change.localMember && change.snapshotMember) { + } else if ( + change.action === "update" && + change.localMember && + change.snapshotMember + ) { const sm = change.snapshotMember; await repos.memberRepo.update( guildId, @@ -354,8 +502,15 @@ async function applyChange( // ── Activity ────────────────────────────────────────────────────────────────── -const ACTIVITY_TYPE: Record>> = { - member: { add: "member.joined", update: "member.roles_changed", deactivate: "member.left" }, +const ACTIVITY_TYPE: Record< + CoreSyncChange["entity"], + Partial> +> = { + member: { + add: "member.joined", + update: "member.roles_changed", + deactivate: "member.left", + }, pass: { add: "pass.created", update: "pass.updated" }, guild: { update: "guild.updated" }, }; diff --git a/packages/integration-client/src/client.ts b/packages/integration-client/src/client.ts index dfbf867..6d936f1 100644 --- a/packages/integration-client/src/client.ts +++ b/packages/integration-client/src/client.ts @@ -1,12 +1,23 @@ -import type { GuildSnapshot, IntegrationClientOptions, Membership, VerificationProof, VerificationResult } from "./types.js"; // IC: 71 +import type { + GuildSnapshot, + IntegrationClientOptions, + Membership, + VerificationProof, + VerificationResult, +} from "./types.js"; import { HttpClient } from "./http/httpClient.js"; import { ContractClient } from "./contracts/contractClient.js"; import type { HttpRequestOptions } from "./http/http.types.js"; +import { CircuitOpenError } from "./http/circuitBreaker.js"; +import { TimeoutError, UpstreamError, NetworkError } from "./http/errors.js"; + +// Re-export typed errors so callers can import them from @guildpass/integration-client +export { CircuitOpenError, TimeoutError, UpstreamError, NetworkError }; function headers(apiKey?: string) { - const h: Record = { "content-type": "application/json" }; // IC: 72 - if (apiKey) h["authorization"] = `Bearer ${apiKey}`; // IC: 73 - return h; // IC: 74 + const h: Record = { "content-type": "application/json" }; + if (apiKey) h["authorization"] = `Bearer ${apiKey}`; + return h; } /** @@ -16,9 +27,18 @@ function headers(apiKey?: string) { * wallet verification) and exposes a {@link ContractClient} factory for * talking to an on-chain RPC endpoint through the same transport. * + * Every method can throw one of three distinguishable errors, all of which + * extend `Error`: + * + * - {@link CircuitOpenError} — the circuit breaker is open; the upstream is + * known to be failing and the request was rejected without network I/O. + * - {@link TimeoutError} — the request exceeded its configured timeout. + * - {@link UpstreamError} — the upstream responded with a non-OK, non-404 + * status after retries were exhausted. Carries a `.status` property. + * * @example * ```ts - * import { IntegrationClient } from "@guildpass/integration-client"; + * import { IntegrationClient, CircuitOpenError, TimeoutError, UpstreamError } from "@guildpass/integration-client"; * * const client = new IntegrationClient({ * baseUrl: "https://core.guildpass.example", @@ -27,8 +47,8 @@ function headers(apiKey?: string) { * ``` */ export class IntegrationClient { - private baseUrl: string; // IC: 75 - private apiKey?: string; // IC: 76 + private baseUrl: string; + private apiKey?: string; private httpClient: HttpClient; /** @@ -43,8 +63,8 @@ export class IntegrationClient { * {@link ./http/http.types} for the field defaults. */ constructor(opts: IntegrationClientOptions) { - this.baseUrl = opts.baseUrl.replace(/\/+$/, ""); // IC: 77 - this.apiKey = opts.apiKey; // IC: 78 + this.baseUrl = opts.baseUrl.replace(/\/+$/, ""); + this.apiKey = opts.apiKey; this.httpClient = new HttpClient(opts.transport); } @@ -68,19 +88,25 @@ export class IntegrationClient { * @param discordUserId - The Discord user id to resolve. * @param options - Per-request {@link HttpRequestOptions} (timeout/retry/headers). * @returns The matching {@link Membership}, or `null` when the user has no - * membership (HTTP 404). Throws `Error("core:")` on any - * other non-OK response. + * membership (HTTP 404). Throws on network/upstream errors. + * @throws {CircuitOpenError} When the circuit breaker is open. + * @throws {TimeoutError} When the request times out. + * @throws {UpstreamError} When the upstream returns a non-OK status (except 404). */ - async getMembershipByDiscordUser(discordUserId: string, options: HttpRequestOptions = {}): Promise { - const url = `${this.baseUrl}/v1/memberships/discord/${encodeURIComponent(discordUserId)}`; // IC: 79 + async getMembershipByDiscordUser( + discordUserId: string, + options: HttpRequestOptions = {}, + ): Promise { + const url = `${this.baseUrl}/v1/memberships/discord/${encodeURIComponent( + discordUserId, + )}`; const res = await this.httpClient.request(url, { ...options, - headers: { ...headers(this.apiKey), ...options.headers } - }); // IC: 80 - if (res.status === 404) return null; // IC: 81 - if (!res.ok) throw new Error(`core:${res.status}`); // IC: 82 - const data = await res.json(); // IC: 83 - return data as Membership; // IC: 84 + headers: { ...headers(this.apiKey), ...options.headers }, + }); + if (res.status === 404) return null; + const data = await res.json(); + return data as Membership; } /** @@ -89,19 +115,25 @@ export class IntegrationClient { * @param wallet - The wallet address to resolve. * @param options - Per-request {@link HttpRequestOptions} (timeout/retry/headers). * @returns The matching {@link Membership}, or `null` when the wallet has no - * membership (HTTP 404). Throws `Error("core:")` on any - * other non-OK response. + * membership (HTTP 404). Throws on network/upstream errors. + * @throws {CircuitOpenError} When the circuit breaker is open. + * @throws {TimeoutError} When the request times out. + * @throws {UpstreamError} When the upstream returns a non-OK status (except 404). */ - async getMembershipByWallet(wallet: string, options: HttpRequestOptions = {}): Promise { - const url = `${this.baseUrl}/v1/memberships/wallet/${encodeURIComponent(wallet)}`; // IC: 85 + async getMembershipByWallet( + wallet: string, + options: HttpRequestOptions = {}, + ): Promise { + const url = `${this.baseUrl}/v1/memberships/wallet/${encodeURIComponent( + wallet, + )}`; const res = await this.httpClient.request(url, { ...options, - headers: { ...headers(this.apiKey), ...options.headers } - }); // IC: 86 - if (res.status === 404) return null; // IC: 87 - if (!res.ok) throw new Error(`core:${res.status}`); // IC: 88 - const data = await res.json(); // IC: 89 - return data as Membership; // IC: 90 + headers: { ...headers(this.apiKey), ...options.headers }, + }); + if (res.status === 404) return null; + const data = await res.json(); + return data as Membership; } /** @@ -113,17 +145,24 @@ export class IntegrationClient { * @param guildId - The guild to snapshot. * @param options - Per-request {@link HttpRequestOptions} (timeout/retry/headers). * @returns The {@link GuildSnapshot}, or `null` when core does not expose a - * snapshot endpoint or has no such guild (HTTP 404). Throws - * `Error("core:")` on any other non-OK response. + * snapshot endpoint or has no such guild (HTTP 404). Throws on + * network/upstream errors. + * @throws {CircuitOpenError} When the circuit breaker is open. + * @throws {TimeoutError} When the request times out. + * @throws {UpstreamError} When the upstream returns a non-OK status (except 404). */ - async getGuildSnapshot(guildId: string, options: HttpRequestOptions = {}): Promise { - const url = `${this.baseUrl}/v1/guilds/${encodeURIComponent(guildId)}/snapshot`; + async getGuildSnapshot( + guildId: string, + options: HttpRequestOptions = {}, + ): Promise { + const url = `${this.baseUrl}/v1/guilds/${encodeURIComponent( + guildId, + )}/snapshot`; const res = await this.httpClient.request(url, { ...options, - headers: { ...headers(this.apiKey), ...options.headers } + headers: { ...headers(this.apiKey), ...options.headers }, }); if (res.status === 404) return null; - if (!res.ok) throw new Error(`core:${res.status}`); const data = await res.json(); return data as GuildSnapshot; } @@ -140,19 +179,28 @@ export class IntegrationClient { * @param options - Per-request {@link HttpRequestOptions} (timeout/retry/headers), * plus optional `proof` ({@link VerificationProof}). * @returns The {@link VerificationResult} (`{ userId, wallet, verified, message? }`). - * Throws `Error("core:")` on any non-OK response. + * @throws {CircuitOpenError} When the circuit breaker is open. + * @throws {TimeoutError} When the request times out. + * @throws {UpstreamError} When the upstream returns a non-OK status. */ - async verifyWallet(discordUserId: string, wallet: string, options: HttpRequestOptions & { proof?: VerificationProof } = {}): Promise { - const url = `${this.baseUrl}/v1/verify`; // IC: 91 + async verifyWallet( + discordUserId: string, + wallet: string, + options: HttpRequestOptions & { proof?: VerificationProof } = {}, + ): Promise { + const url = `${this.baseUrl}/v1/verify`; const { proof, ...requestOptions } = options; const res = await this.httpClient.request(url, { ...requestOptions, method: "POST", headers: { ...headers(this.apiKey), ...requestOptions.headers }, - body: JSON.stringify({ discordUserId, wallet, ...(proof ? { proof } : {}) }) - }); // IC: 92 - if (!res.ok) throw new Error(`core:${res.status}`); // IC: 93 - const data = await res.json(); // IC: 94 - return data as VerificationResult; // IC: 95 + body: JSON.stringify({ + discordUserId, + wallet, + ...(proof ? { proof } : {}), + }), + }); + const data = await res.json(); + return data as VerificationResult; } } diff --git a/packages/integration-client/src/http/errors.ts b/packages/integration-client/src/http/errors.ts new file mode 100644 index 0000000..2f188f0 --- /dev/null +++ b/packages/integration-client/src/http/errors.ts @@ -0,0 +1,78 @@ +/** + * Typed error classes for the HTTP transport. + * + * Every error surfaced by HttpClient is one of these, so callers can use + * `instanceof` to distinguish error classes and render appropriate UI states + * (e.g. "GuildPass core is temporarily unavailable" for CircuitOpenError + * vs. a degraded partial-state view for TimeoutError). + * + * @module + */ + +/** + * Thrown when a request exceeds its configured timeout. + * + * Distinguishable from network errors by its `code === "timeout"` and its + * distinct constructor name `TimeoutError`. + */ +export class TimeoutError extends Error { + readonly code = "timeout" as const; + readonly timeoutMs: number; + + constructor(timeoutMs: number) { + super( + `Request timed out after ${timeoutMs}ms. The upstream may be slow or unreachable.`, + ); + this.name = "TimeoutError"; + this.timeoutMs = timeoutMs; + } +} + +/** + * Thrown when an upstream responds with an error status after all retries + * have been exhausted, or when the transport encounters a non-retryable + * upstream signal (e.g. a 4xx that wasn't a 429). + * + * Carries the HTTP status code so callers can differentiate 503 (overloaded) + * from 502 (bad gateway) from 404 (not found, treated as non-error by some + * callers). + */ +export class UpstreamError extends Error { + readonly code = "upstream" as const; + readonly status: number; + readonly statusText: string; + + constructor(status: number, statusText?: string) { + super( + `Upstream responded with ${status}${ + statusText ? ` (${statusText})` : "" + }${ + status >= 500 + ? ". The upstream may be experiencing issues; retry later." + : "" + }`, + ); + this.name = "UpstreamError"; + this.status = status; + this.statusText = statusText ?? ""; + } +} + +/** + * Thrown when a fetch-level error (network, DNS, TLS, etc.) makes a request + * impossible — distinct from an upstream that responded with 5xx. + */ +export class NetworkError extends Error { + readonly code = "network" as const; + readonly cause: unknown; + + constructor(cause: unknown) { + super( + cause instanceof Error + ? `Network error: ${cause.message}` + : "A network error occurred while trying to reach the upstream.", + ); + this.name = "NetworkError"; + this.cause = cause; + } +} diff --git a/packages/integration-client/src/http/http.types.ts b/packages/integration-client/src/http/http.types.ts index 03a90a9..fa3893e 100644 --- a/packages/integration-client/src/http/http.types.ts +++ b/packages/integration-client/src/http/http.types.ts @@ -1,63 +1,68 @@ import type { CircuitBreakerConfig } from "./circuitBreaker.js"; +export type { CircuitBreakerConfig }; + +// Re-export typed error classes so consumers can import them from a single +// path (@guildpass/integration-client) rather than digging into ./errors.js. +export { TimeoutError, UpstreamError, NetworkError } from "./errors.js"; /** -* Retry configuration for a single HTTP request (or the default for all -* requests on a client when set via {@link TransportConfig.retry}). -* -* Defaults (used when a field is omitted): -* - `maxAttempts` defaults to `3` (via DEFAULT_RETRY_CONFIG). Set `maxAttempts: 1` to disable retries. -* - `delay` defaults to `200` ms between attempts (via DEFAULT_RETRY_CONFIG). -* - `backoff` defaults to `true` (exponential backoff) (via DEFAULT_RETRY_CONFIG). -*/ + * Retry configuration for a single HTTP request (or the default for all + * requests on a client when set via {@link TransportConfig.retry}). + * + * Defaults (used when a field is omitted): + * - `maxAttempts` defaults to `3` (via DEFAULT_RETRY_CONFIG). Set `maxAttempts: 1` to disable retries. + * - `delay` defaults to `200` ms between attempts (via DEFAULT_RETRY_CONFIG). + * - `backoff` defaults to `true` (exponential backoff) (via DEFAULT_RETRY_CONFIG). + */ export interface RetryConfig { - /** Maximum number of attempts before giving up. `1` = no retry. */ - maxAttempts: number; -/** Base delay between attempts, in milliseconds. */ -delay?: number; // ms -/** When `true`, use exponential backoff: `delay * 2^(attempt-1)`. */ -backoff?: boolean; + /** Maximum number of attempts before giving up. `1` = no retry. */ + maxAttempts: number; + /** Base delay between attempts, in milliseconds. */ + delay?: number; // ms + /** When `true`, use exponential backoff: `delay * 2^(attempt-1)`. */ + backoff?: boolean; } export const DEFAULT_RETRY_CONFIG: RetryConfig = { -maxAttempts: 3, -delay: 200, -backoff: true, + maxAttempts: 3, + delay: 200, + backoff: true, }; /** -* Per-request options accepted by `HttpClient.request` and every client method. -* -* Extends the standard `RequestInit` (minus `signal`, which is reserved for -* abort control) and adds `timeout` and `retry`. -* -* Note: a per-request `timeout`/`retry` overrides the client-level defaults -* supplied through {@link TransportConfig}. -*/ + * Per-request options accepted by `HttpClient.request` and every client method. + * + * Extends the standard `RequestInit` (minus `signal`, which is reserved for + * abort control) and adds `timeout` and `retry`. + * + * Note: a per-request `timeout`/`retry` overrides the client-level defaults + * supplied through {@link TransportConfig}. + */ export interface HttpRequestOptions extends Omit { -/** Per-request timeout in milliseconds. Overrides {@link TransportConfig.timeout}. No timeout when omitted. */ -timeout?: number; // ms -/** Per-request retry strategy. Overrides {@link TransportConfig.retry}. */ -retry?: RetryConfig; -/** Abort signal for cancelling the request. */ -signal?: AbortSignal; + /** Per-request timeout in milliseconds. Overrides {@link TransportConfig.timeout}. No timeout when omitted. */ + timeout?: number; // ms + /** Per-request retry strategy. Overrides {@link TransportConfig.retry}. */ + retry?: RetryConfig; + /** Abort signal for cancelling the request. */ + signal?: AbortSignal; } /** -* Client-level transport configuration for {@link IntegrationClientOptions.transport}. -* -* These values become the **defaults** applied to every request unless a -* per-request {@link HttpRequestOptions} overrides them. -* -* Defaults (used when a field is omitted): -* - `fetch` defaults to the global `fetch`. -* - `timeout` is **unset** (no timeout) unless provided. -* - `retry` defaults to `DEFAULT_RETRY_CONFIG` (3 attempts, 200ms base delay, exponential backoff). -*/ + * Client-level transport configuration for {@link IntegrationClientOptions.transport}. + * + * These values become the **defaults** applied to every request unless a + * per-request {@link HttpRequestOptions} overrides them. + * + * Defaults (used when a field is omitted): + * - `fetch` defaults to the global `fetch`. + * - `timeout` is **unset** (no timeout) unless provided. + * - `retry` defaults to `DEFAULT_RETRY_CONFIG` (3 attempts, 200ms base delay, exponential backoff). + */ export interface TransportConfig { -/** Custom fetch implementation (e.g. for Node < 18, testing, or proxies). Default: global `fetch`. */ -fetch?: typeof fetch; -/** Default request timeout in milliseconds. Default: no timeout. */ -timeout?: number; -/** Default retry strategy for all requests. Default: 3 attempts with backoff. */ -retry?: RetryConfig; + /** Custom fetch implementation (e.g. for Node < 18, testing, or proxies). Default: global `fetch`. */ + fetch?: typeof fetch; + /** Default request timeout in milliseconds. Default: no timeout. */ + timeout?: number; + /** Default retry strategy for all requests. Default: 3 attempts with backoff. */ + retry?: RetryConfig; circuitBreaker?: CircuitBreakerConfig; -} \ No newline at end of file +} diff --git a/packages/integration-client/src/http/httpClient.ts b/packages/integration-client/src/http/httpClient.ts index 5bcfa9e..0bf768a 100644 --- a/packages/integration-client/src/http/httpClient.ts +++ b/packages/integration-client/src/http/httpClient.ts @@ -1,5 +1,14 @@ -import { type HttpRequestOptions, type TransportConfig, DEFAULT_RETRY_CONFIG } from "./http.types.js"; -import { CircuitBreaker, CircuitOpenError, type CircuitBreakerStatus } from "./circuitBreaker.js"; +import { + type HttpRequestOptions, + type TransportConfig, + DEFAULT_RETRY_CONFIG, +} from "./http.types.js"; +import { + CircuitBreaker, + CircuitOpenError, + type CircuitBreakerStatus, +} from "./circuitBreaker.js"; +import { TimeoutError, UpstreamError, NetworkError } from "./errors.js"; export class HttpClient { private config: TransportConfig; @@ -15,7 +24,10 @@ export class HttpClient { } } - async request(url: string, options: HttpRequestOptions = {}): Promise { + async request( + url: string, + options: HttpRequestOptions = {}, + ): Promise { if (this.breaker && !this.breaker.canRequest()) { const status = this.breaker.getStatus(); throw new CircuitOpenError(status.retryAt ?? Date.now()); @@ -31,6 +43,7 @@ export class HttpClient { const fetchFn = this.config.fetch ?? fetch; const maxAttempts = retry?.maxAttempts ?? 1; let attempt = 0; + let lastError: Error | undefined; while (attempt < maxAttempts) { attempt++; @@ -42,14 +55,15 @@ export class HttpClient { }; if (externalSignal) { - if (externalSignal.aborted) throw externalSignal.reason ?? new Error("Aborted"); + if (externalSignal.aborted) + throw externalSignal.reason ?? new Error("Aborted"); externalSignal.addEventListener("abort", onAbort); } let timeoutId: ReturnType | undefined; if (timeout) { timeoutId = setTimeout(() => { - controller.abort(new Error(`Timeout: Request exceeded ${timeout}ms`)); + controller.abort(new TimeoutError(timeout)); }, timeout); } @@ -59,38 +73,80 @@ export class HttpClient { signal, }); - if (response.ok || attempt >= maxAttempts || !this.isTransient(response.status)) { - if (this.breaker) { - if (response.ok || !this.isTransient(response.status)) { - this.breaker.recordSuccess(); - } else { - this.breaker.recordFailure(); - } - } + if (response.ok) { + if (this.breaker) this.breaker.recordSuccess(); return response; } + + // Non-OK response: check if we should retry (transient) or fail. + if (attempt >= maxAttempts || !this.isTransient(response.status)) { + if (this.breaker) this.breaker.recordFailure(); + throw new UpstreamError(response.status, response.statusText); + } + + // Transient status — will retry after the loop body ends. + lastError = new UpstreamError(response.status, response.statusText); } catch (error: any) { - if (externalSignal?.aborted && (error === externalSignal.reason || error.name === "AbortError")) { + // If the external signal was aborted by the caller, re-throw as-is. + if ( + externalSignal?.aborted && + (error === externalSignal.reason || error.name === "AbortError") + ) { throw externalSignal.reason ?? error; } + + // If this is already one of our typed errors, capture it for re-throw. + if ( + error instanceof TimeoutError || + error instanceof UpstreamError || + error instanceof CircuitOpenError + ) { + lastError = error; + } else { + // Wrap raw fetch/network errors. + lastError = + error instanceof Error + ? new NetworkError(error) + : new NetworkError(error); + } + + // If this is the last attempt, give up. if (attempt >= maxAttempts) { + this.breaker?.recordFailure(); + throw lastError; + } + + // TimeoutError and network errors are retryable. + if (error instanceof TimeoutError || error instanceof NetworkError) { + // Continue to retry below. + } else if ( + error instanceof UpstreamError && + this.isTransient(error.status) + ) { + // Transient 5xx/429 is retryable — continue. + } else { + // Non-retryable upstream error — fail immediately. this.breaker?.recordFailure(); throw error; } } finally { if (timeoutId) clearTimeout(timeoutId); - if (externalSignal) externalSignal.removeEventListener("abort", onAbort); + if (externalSignal) + externalSignal.removeEventListener("abort", onAbort); } - if (retry) { + // Exponential backoff before next attempt. + if (retry && attempt < maxAttempts) { const delay = retry.delay ?? 1000; - const sleepTime = retry.backoff ? delay * Math.pow(2, attempt - 1) : delay; + const sleepTime = retry.backoff + ? delay * Math.pow(2, attempt - 1) + : delay; await new Promise((resolve) => setTimeout(resolve, sleepTime)); } } this.breaker?.recordFailure(); - throw new Error("Request failed after max attempts"); + throw lastError ?? new Error("Request failed after max attempts"); } getStatus(): CircuitBreakerStatus | null { diff --git a/packages/integration-client/src/index.ts b/packages/integration-client/src/index.ts index 3b8318b..34af0d3 100644 --- a/packages/integration-client/src/index.ts +++ b/packages/integration-client/src/index.ts @@ -1,6 +1,16 @@ export * from "./types.js"; // IC: 96 export { IntegrationClient } from "./client.js"; // IC: 97 -export * from "./http/http.types.js"; +export { + CircuitOpenError, + TimeoutError, + UpstreamError, + NetworkError, +} from "./client.js"; +export type { + HttpRequestOptions, + RetryConfig, + TransportConfig, +} from "./http/http.types.js"; export * from "./contracts/contract.types.js"; export { ContractClient } from "./contracts/contractClient.js"; export {