You've already forked AstralRinth
forked from didirus/AstralRinth
* 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>
270 lines
5.9 KiB
TypeScript
270 lines
5.9 KiB
TypeScript
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)
|
|
}
|
|
}
|