feat: base api-client impl (#4694)

* feat: base api-client impl

* fix: doc

* feat: start work on module stuff

* feat: migrate v2/v3 projects into module system

* fix: lint & README.md contributing

* refactor: remove utils old api client prototype

* fix: lint

* fix: api url issues

* fix: baseurl in error.vue

* fix: readme

* fix typo in readme

* Update apps/frontend/src/providers/api-client.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>

* Update packages/api-client/src/features/verbose-logging.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>

* Update packages/api-client/src/features/retry.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>

---------

Signed-off-by: Calum H. <hendersoncal117@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Calum H.
2025-11-12 20:29:12 +00:00
committed by GitHub
parent 590ba3ce55
commit 70e2138248
45 changed files with 3155 additions and 123 deletions

View File

@@ -0,0 +1,89 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import type { RequestContext } from '../types/request'
/**
* Authentication feature configuration
*/
export interface AuthConfig extends FeatureConfig {
/**
* Authentication token
* - string: static token
* - function: async function that returns token (useful for dynamic tokens)
*/
token: string | (() => Promise<string | undefined>)
/**
* Token prefix (e.g., 'Bearer', 'Token')
* @default 'Bearer'
*/
tokenPrefix?: string
/**
* Custom header name for the token
* @default 'Authorization'
*/
headerName?: string
}
/**
* Authentication feature
*
* Automatically injects authentication tokens into request headers.
* Supports both static tokens and dynamic token providers.
*
* @example
* ```typescript
* // Static token
* const auth = new AuthFeature({
* token: 'mrp_...'
* })
*
* // Dynamic token (e.g., from auth state)
* const auth = new AuthFeature({
* token: async () => await getAuthToken()
* })
* ```
*/
export class AuthFeature extends AbstractFeature {
protected declare config: AuthConfig
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const token = await this.getToken()
if (token) {
const headerName = this.config.headerName ?? 'Authorization'
const tokenPrefix = this.config.tokenPrefix ?? 'Bearer'
const headerValue = tokenPrefix ? `${tokenPrefix} ${token}` : token
context.options.headers = {
...context.options.headers,
[headerName]: headerValue,
}
}
return next()
}
shouldApply(context: RequestContext): boolean {
if (context.options.skipAuth) {
return false
}
return super.shouldApply(context)
}
/**
* Get the authentication token
*
* Handles both static tokens and async token providers
*/
private async getToken(): Promise<string | undefined> {
const { token } = this.config
if (typeof token === 'function') {
return await token()
}
return token
}
}

View File

@@ -0,0 +1,269 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
/**
* Circuit breaker state
*/
export type CircuitBreakerState = {
/**
* Number of consecutive failures
*/
failures: number
/**
* Timestamp of last failure
*/
lastFailure: number
}
/**
* Circuit breaker storage interface
*/
export interface CircuitBreakerStorage {
/**
* Get circuit breaker state for a key
*/
get(key: string): CircuitBreakerState | undefined
/**
* Set circuit breaker state for a key
*/
set(key: string, state: CircuitBreakerState): void
/**
* Clear circuit breaker state for a key
*/
clear?(key: string): void
}
/**
* Circuit breaker feature configuration
*/
export interface CircuitBreakerConfig extends FeatureConfig {
/**
* Maximum number of consecutive failures before opening circuit
* @default 3
*/
maxFailures?: number
/**
* Time in milliseconds before circuit resets after opening
* @default 30000
*/
resetTimeout?: number
/**
* HTTP status codes that count as failures
* @default [500, 502, 503, 504]
*/
failureStatusCodes?: number[]
/**
* Storage implementation for circuit state
* If not provided, uses in-memory Map
*/
storage?: CircuitBreakerStorage
/**
* Function to generate circuit key from request context
* By default, uses the base path (without query params)
*/
getCircuitKey?: (url: string, method: string) => string
}
/**
* In-memory storage for circuit breaker state
*/
export class InMemoryCircuitBreakerStorage implements CircuitBreakerStorage {
private state = new Map<string, CircuitBreakerState>()
get(key: string): CircuitBreakerState | undefined {
return this.state.get(key)
}
set(key: string, state: CircuitBreakerState): void {
this.state.set(key, state)
}
clear(key: string): void {
this.state.delete(key)
}
}
/**
* Circuit breaker feature
*
* Prevents requests to failing endpoints by "opening the circuit" after
* a threshold of consecutive failures. The circuit automatically resets
* after a timeout period.
*
* This implements the circuit breaker pattern to prevent cascading failures
* and give failing services time to recover.
*
* @example
* ```typescript
* const circuitBreaker = new CircuitBreakerFeature({
* maxFailures: 3,
* resetTimeout: 30000, // 30 seconds
* failureStatusCodes: [500, 502, 503, 504]
* })
* ```
*/
export class CircuitBreakerFeature extends AbstractFeature {
protected declare config: Required<CircuitBreakerConfig>
private storage: CircuitBreakerStorage
constructor(config?: CircuitBreakerConfig) {
super(config)
this.config = {
enabled: true,
name: 'circuit-breaker',
maxFailures: 3,
resetTimeout: 30000,
failureStatusCodes: [500, 502, 503, 504],
...config,
} as Required<CircuitBreakerConfig>
// Use provided storage or default to in-memory
this.storage = config?.storage ?? new InMemoryCircuitBreakerStorage()
}
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const circuitKey = this.getCircuitKey(context)
if (this.isCircuitOpen(circuitKey)) {
throw new ModrinthApiError('Circuit breaker open - too many recent failures', {
statusCode: 503,
context: context.path,
})
}
try {
const result = await next()
this.recordSuccess(circuitKey)
return result
} catch (error) {
if (this.isFailureError(error)) {
this.recordFailure(circuitKey)
}
throw error
}
}
shouldApply(context: RequestContext): boolean {
if (context.options.circuitBreaker === false) {
return false
}
return super.shouldApply(context)
}
/**
* Get the circuit key for a request
*
* By default, uses the path and method to identify unique circuits
*/
private getCircuitKey(context: RequestContext): string {
if (this.config.getCircuitKey) {
return this.config.getCircuitKey(context.url, context.options.method ?? 'GET')
}
// Default: use method + path (without query params)
const method = context.options.method ?? 'GET'
const pathWithoutQuery = context.path.split('?')[0]
return `${method}_${pathWithoutQuery}`
}
/**
* Check if the circuit is open for a given key
*/
private isCircuitOpen(key: string): boolean {
const state = this.storage.get(key)
if (!state) {
return false
}
const now = Date.now()
const timeSinceLastFailure = now - state.lastFailure
if (timeSinceLastFailure > this.config.resetTimeout) {
this.storage.clear?.(key)
return false
}
return state.failures >= this.config.maxFailures
}
/**
* Record a successful request
*/
private recordSuccess(key: string): void {
this.storage.clear?.(key)
}
/**
* Record a failed request
*/
private recordFailure(key: string): void {
const now = Date.now()
const state = this.storage.get(key)
if (!state) {
// First failure
this.storage.set(key, {
failures: 1,
lastFailure: now,
})
} else {
// Subsequent failure
this.storage.set(key, {
failures: state.failures + 1,
lastFailure: now,
})
}
}
/**
* Determine if an error should count as a circuit failure
*/
private isFailureError(error: unknown): boolean {
if (error instanceof ModrinthApiError && error.statusCode) {
return this.config.failureStatusCodes.includes(error.statusCode)
}
return false
}
/**
* Get current circuit state for debugging
*
* @example
* ```typescript
* const state = circuitBreaker.getCircuitState('GET_/v2/project/sodium')
* console.log(`Failures: ${state?.failures}, Last failure: ${state?.lastFailure}`)
* ```
*/
getCircuitState(key: string): CircuitBreakerState | undefined {
return this.storage.get(key)
}
/**
* Manually reset a circuit
*
* @example
* ```typescript
* // Reset circuit after manual intervention
* circuitBreaker.resetCircuit('GET_/v2/project/sodium')
* ```
*/
resetCircuit(key: string): void {
this.storage.clear?.(key)
}
}

View File

@@ -0,0 +1,220 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
/**
* Backoff strategy for retries
*/
export type BackoffStrategy = 'exponential' | 'linear' | 'constant'
/**
* Retry feature configuration
*/
export interface RetryConfig extends FeatureConfig {
/**
* Maximum number of retry attempts
* @default 3
*/
maxAttempts?: number
/**
* Backoff strategy to use
* @default 'exponential'
*/
backoffStrategy?: BackoffStrategy
/**
* Initial delay in milliseconds before first retry
* @default 1000
*/
initialDelay?: number
/**
* Maximum delay in milliseconds between retries
* @default 15000
*/
maxDelay?: number
/**
* HTTP status codes that should trigger a retry
* @default [408, 429, 500, 502, 503, 504]
*/
retryableStatusCodes?: number[]
/**
* Whether to retry on network errors (connection refused, timeout, etc.)
* @default true
*/
retryOnNetworkError?: boolean
/**
* Custom function to determine if an error should be retried
*/
shouldRetry?: (error: unknown, attempt: number) => boolean
}
/**
* Retry feature
*
* Automatically retries failed requests with configurable backoff strategy.
* Only retries errors that are likely to succeed on retry (e.g., timeout, 5xx errors).
*
* @example
* ```typescript
* const retry = new RetryFeature({
* maxAttempts: 3,
* backoffStrategy: 'exponential',
* initialDelay: 1000,
* maxDelay: 15000
* })
* ```
*/
export class RetryFeature extends AbstractFeature {
protected declare config: Required<RetryConfig>
constructor(config?: RetryConfig) {
super(config)
this.config = {
enabled: true,
name: 'retry',
maxAttempts: 3,
backoffStrategy: 'exponential',
initialDelay: 1000,
maxDelay: 15000,
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
retryOnNetworkError: true,
...config,
} as Required<RetryConfig>
}
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
let lastError: Error | null = null
const maxAttempts = this.getMaxAttempts(context)
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
context.attempt = attempt
try {
const result = await next()
return result
} catch (error) {
lastError = error as Error
const shouldRetry = this.shouldRetryError(error, attempt, maxAttempts)
if (!shouldRetry || attempt >= maxAttempts) {
throw error
}
const delay = this.calculateDelay(attempt)
console.warn(
`[${this.name}] Retrying request to ${context.path} (attempt ${attempt + 1}/${maxAttempts}) after ${delay}ms`,
)
await this.sleep(delay)
}
}
// This shouldn't be reached, but TypeScript requires it
throw lastError ?? new Error('Max retry attempts reached')
}
shouldApply(context: RequestContext): boolean {
if (context.options.retry === false) {
return false
}
return super.shouldApply(context)
}
/**
* Determine if an error should be retried
*/
private shouldRetryError(error: unknown, attempt: number, _maxAttempts: number): boolean {
if (this.config.shouldRetry) {
return this.config.shouldRetry(error, attempt)
}
if (this.config.retryOnNetworkError && this.isNetworkError(error)) {
return true
}
if (error instanceof ModrinthApiError && error.statusCode) {
return this.config.retryableStatusCodes.includes(error.statusCode)
}
return false
}
/**
* Check if an error is a network error
*/
private isNetworkError(error: unknown): boolean {
// Common network error indicators
const networkErrorPatterns = [
/network/i,
/timeout/i,
/ECONNREFUSED/i,
/ENOTFOUND/i,
/ETIMEDOUT/i,
/ECONNRESET/i,
]
const errorMessage = error instanceof Error ? error.message : String(error)
return networkErrorPatterns.some((pattern) => pattern.test(errorMessage))
}
/**
* Get max attempts for this request
*/
private getMaxAttempts(context: RequestContext): number {
if (typeof context.options.retry === 'number') {
return context.options.retry
}
return this.config.maxAttempts
}
/**
* Calculate delay before next retry based on backoff strategy
*/
private calculateDelay(attempt: number): number {
const { backoffStrategy, initialDelay, maxDelay } = this.config
let delay: number
switch (backoffStrategy) {
case 'exponential':
// Exponential: delay = initialDelay * 2^(attempt-1)
delay = initialDelay * Math.pow(2, attempt - 1)
break
case 'linear':
// Linear: delay = initialDelay * attempt
delay = initialDelay * attempt
break
case 'constant':
// Constant: delay = initialDelay
delay = initialDelay
break
default:
delay = initialDelay
}
// Add jitter (random 0-1000ms) to prevent thundering herd
delay += Math.random() * 1000
return Math.min(delay, maxDelay)
}
/**
* Sleep for a given duration
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}

View File

@@ -0,0 +1,30 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import type { RequestContext } from '../types/request'
export type VerboseLoggingConfig = FeatureConfig
export class VerboseLoggingFeature extends AbstractFeature {
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const method = context.options.method ?? 'GET'
const api = context.options.api
const version = context.options.version
const prefix = `[${method}] [${api}_v${version}]`
console.debug(`${prefix} ${context.url} SENT`)
try {
const result = await next()
try {
const size = result ? JSON.stringify(result).length : 0
console.debug(`${prefix} ${context.url} RECEIVED ${size} bytes`)
} catch {
// ignore size calc fail
console.debug(`${prefix} ${context.url} RECEIVED`)
}
return result
} catch (error) {
console.debug(`${prefix} ${context.url} FAILED`)
throw error
}
}
}