1
0
Files
AstralRinth/packages/api-client/src/features/circuit-breaker.ts
Calum H. 70e2138248 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>
2025-11-12 20:29:12 +00:00

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)
}
}