You've already forked AstralRinth
forked from didirus/AstralRinth
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:
89
packages/api-client/src/features/auth.ts
Normal file
89
packages/api-client/src/features/auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
269
packages/api-client/src/features/circuit-breaker.ts
Normal file
269
packages/api-client/src/features/circuit-breaker.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
220
packages/api-client/src/features/retry.ts
Normal file
220
packages/api-client/src/features/retry.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
30
packages/api-client/src/features/verbose-logging.ts
Normal file
30
packages/api-client/src/features/verbose-logging.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user