Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/client/GuildPassClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { AccessCheckParams, RoleAccessCheckParams, AccessCheckBatchOptions,
import type { MembershipParams } from '../membership/membership.types';
import type { GetRolesParams, GetUserRolesParams, HasRoleParams } from '../roles/roles.types';
import type { GetGuildParams } from '../guilds/guilds.types';
import { DiagnosticsModule } from '../diagnostics/DiagnosticsModule';

/**
* The main GuildPass SDK this.
Expand Down Expand Up @@ -84,6 +85,7 @@ export class GuildPassClient {
public readonly guilds: GuildsService;
// GuildPass SDK: Class member structure property or constructor.
public readonly contracts: ContractClient;
public readonly diagnostics: DiagnosticsModule;

// GuildPass SDK: Class member structure property or constructor.
private readonly http: HttpClient;
Expand Down Expand Up @@ -162,6 +164,38 @@ export class GuildPassClient {
this.roles = this.buildCachedRolesService(rawRoles);
this.guilds = this.buildCachedGuildsService(rawGuilds);
this.contracts = rawContracts;

this.diagnostics = new DiagnosticsModule();
this.diagnostics.registerInFlightRequests(() => this.inFlightRequests.size);
this.diagnostics.registerRateLimit(() => this.http.getRateLimitStatus());
this.diagnostics.registerCircuitBreakers(() => this.contracts.getCircuitBreakerSnapshot());

// Hook up rate limit events if token bucket exists
if (this.config.rateLimit) {
const originalOnThrottled = (this.config.rateLimit as any).onThrottled;
(this.config.rateLimit as any).onThrottled = (until: number) => {
this.diagnostics.emit('rateLimitThrottled', { throttlingUntil: until });
if (originalOnThrottled) originalOnThrottled(until);
};
}

// Hook up circuit breaker events if using adaptive provider
if (this.config.contractProvider && (this.config.contractProvider as any).healthTracker) {
const tracker = (this.config.contractProvider as any).healthTracker;
if (tracker && tracker.config) {
const originalOnCircuitOpen = tracker.config.onCircuitOpen;
const originalOnCircuitClosed = tracker.config.onCircuitClosed;
tracker.config.onCircuitOpen = (url: string, until: number) => {
this.diagnostics.emit('circuitOpen', { url, openUntil: until });
if (originalOnCircuitOpen) originalOnCircuitOpen(url, until);
};
tracker.config.onCircuitClosed = (url: string) => {
this.diagnostics.emit('circuitClosed', { url });
if (originalOnCircuitClosed) originalOnCircuitClosed(url);
};
}
}

// GuildPass SDK: End of logic containment structure block.
}

Expand Down Expand Up @@ -318,11 +352,17 @@ export class GuildPassClient {
if (this.cache) {
try {
const cached = await this.cache.get<T>(key);
if (cached !== null) return cached;
if (cached !== null) {
this.diagnostics.recordCacheHit(key);
return cached;
}
} catch (error: any) {
this.diagnostics.recordCacheError();
this.handleCacheError('get', error, key);
}
}

this.diagnostics.recordCacheMiss(key);

const execute = async (): Promise<T> => {
const result = await fn();
Expand Down
10 changes: 10 additions & 0 deletions src/contracts/contractClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,16 @@ export class ContractClient {
return result;
}

public getCircuitBreakerSnapshot(): Record<string, any> {
// ContractClient doesn't hold the AdaptiveContractProvider directly if it is passed in,
// but we can check if the contractProvider is AdaptiveContractProvider and has healthTracker.
const provider = this.config.contractProvider as any;
if (provider && provider.healthTracker && typeof provider.healthTracker.snapshotAll === 'function') {
return provider.healthTracker.snapshotAll();
}
return {};
}

/**
* Fetches the owner of a guild from the contract.
*
Expand Down
4 changes: 4 additions & 0 deletions src/contracts/providers/adaptive.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export type AdaptiveHealthConfig = {
failureThreshold?: number;
/** How long the circuit stays open before a trial call, ms. Default: 30000. */
cooldownMs?: number;
/** Callback fired when a circuit breaker opens. */
onCircuitOpen?: (url: string, openUntil: number) => void;
/** Callback fired when a circuit breaker closes. */
onCircuitClosed?: (url: string) => void;
/** Smoothing factor for the latency EMA, between 0 and 1. Default: 0.3. */
latencyEmaAlpha?: number;
/** Batch size at or above which Multicall3 is strongly preferred. Default: 3. */
Expand Down
31 changes: 28 additions & 3 deletions src/contracts/providers/healthTracker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// GuildPass SDK: Pull in package or module bindings.
import { AdaptiveHealthConfig, UrlHealth } from './adaptive.types';

const DEFAULTS: Required<AdaptiveHealthConfig> = {
const DEFAULTS: Omit<Required<AdaptiveHealthConfig>, 'onCircuitOpen' | 'onCircuitClosed'> & {
onCircuitOpen?: (url: string, openUntil: number) => void;
onCircuitClosed?: (url: string) => void;
} = {
failureThreshold: 3,
cooldownMs: 30000,
latencyEmaAlpha: 0.3,
Expand All @@ -24,7 +27,10 @@ const DEFAULTS: Required<AdaptiveHealthConfig> = {
* response does not dominate routing decisions.
*/
export class HealthTracker {
private readonly config: Required<AdaptiveHealthConfig>;
private readonly config: Omit<Required<AdaptiveHealthConfig>, 'onCircuitOpen' | 'onCircuitClosed'> & {
onCircuitOpen?: (url: string, openUntil: number) => void;
onCircuitClosed?: (url: string) => void;
};
private readonly health = new Map<string, UrlHealth>();

constructor(config?: AdaptiveHealthConfig) {
Expand Down Expand Up @@ -56,13 +62,17 @@ export class HealthTracker {
if (record.circuitOpen && now >= record.openUntil) {
// Cooldown elapsed: allow a half-open trial call through.
record.circuitOpen = false;
if (this.config.onCircuitClosed) {
this.config.onCircuitClosed(url);
}
}
return !record.circuitOpen;
}

/** Records a successful call: resets failures and updates latency EMA. */
public recordSuccess(url: string, latencyMs: number): void {
const record = this.get(url);
const wasOpen = record.circuitOpen;
record.consecutiveFailures = 0;
record.circuitOpen = false;
record.openUntil = 0;
Expand All @@ -71,6 +81,10 @@ export class HealthTracker {
? latencyMs
: this.config.latencyEmaAlpha * latencyMs +
(1 - this.config.latencyEmaAlpha) * record.latencyEmaMs;

if (wasOpen && this.config.onCircuitClosed) {
this.config.onCircuitClosed(url);
}
}

/**
Expand All @@ -80,9 +94,12 @@ export class HealthTracker {
public recordFailure(url: string, now: number = Date.now()): void {
const record = this.get(url);
record.consecutiveFailures += 1;
if (record.consecutiveFailures >= this.config.failureThreshold) {
if (!record.circuitOpen && record.consecutiveFailures >= this.config.failureThreshold) {
record.circuitOpen = true;
record.openUntil = now + this.config.cooldownMs;
if (this.config.onCircuitOpen) {
this.config.onCircuitOpen(url, record.openUntil);
}
}
}

Expand All @@ -102,4 +119,12 @@ export class HealthTracker {
const record = this.health.get(url);
return record ? { ...record } : undefined;
}

public snapshotAll(): Record<string, Readonly<UrlHealth>> {
const result: Record<string, Readonly<UrlHealth>> = {};
for (const [url, record] of this.health.entries()) {
result[url] = { ...record };
}
return result;
}
}
75 changes: 75 additions & 0 deletions src/diagnostics/DiagnosticsModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
CacheDiagnostics,
CircuitBreakerDiagnostics,
DiagnosticsEvents,
DiagnosticsSnapshot,
RateLimitDiagnostics,
} from './diagnostics.types';

export class DiagnosticsModule {
private cacheStats: CacheDiagnostics = { hits: 0, misses: 0, errors: 0 };
private listeners: Record<string, Set<any>> = {};

private inFlightRequestsProvider?: () => number;
private rateLimitProvider?: () => RateLimitDiagnostics | null;
private circuitBreakersProvider?: () => Record<string, CircuitBreakerDiagnostics>;

public on<K extends keyof DiagnosticsEvents>(event: K, handler: DiagnosticsEvents[K]): void {
if (!this.listeners[event as string]) {
this.listeners[event as string] = new Set();
}
this.listeners[event as string].add(handler);
}

public off<K extends keyof DiagnosticsEvents>(event: K, handler: DiagnosticsEvents[K]): void {
if (this.listeners[event as string]) {
this.listeners[event as string].delete(handler);
}
}

public emit<K extends keyof DiagnosticsEvents>(
event: K,
...args: Parameters<DiagnosticsEvents[K]>
): void {
if (this.listeners[event as string]) {
for (const handler of this.listeners[event as string]) {
handler(...args);
}
}
}

public registerInFlightRequests(fn: () => number): void {
this.inFlightRequestsProvider = fn;
}

public registerRateLimit(fn: () => RateLimitDiagnostics | null): void {
this.rateLimitProvider = fn;
}

public registerCircuitBreakers(fn: () => Record<string, CircuitBreakerDiagnostics>): void {
this.circuitBreakersProvider = fn;
}

public recordCacheHit(key: string): void {
this.cacheStats.hits++;
this.emit('cacheHit', { key });
}

public recordCacheMiss(key: string): void {
this.cacheStats.misses++;
this.emit('cacheMiss', { key });
}

public recordCacheError(): void {
this.cacheStats.errors++;
}

public getSnapshot(): DiagnosticsSnapshot {
return {
inFlightRequests: this.inFlightRequestsProvider ? this.inFlightRequestsProvider() : 0,
cache: { ...this.cacheStats },
rateLimit: this.rateLimitProvider ? this.rateLimitProvider() : null,
circuitBreakers: this.circuitBreakersProvider ? this.circuitBreakersProvider() : {},
};
}
}
32 changes: 32 additions & 0 deletions src/diagnostics/diagnostics.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface CacheDiagnostics {
hits: number;
misses: number;
errors: number;
}

export interface CircuitBreakerDiagnostics {
circuitOpen: boolean;
consecutiveFailures: number;
latencyEmaMs: number;
openUntil: number;
}

export interface RateLimitDiagnostics {
throttlingUntil: number;
currentRate: number;
}

export interface DiagnosticsSnapshot {
inFlightRequests: number;
cache: CacheDiagnostics;
rateLimit: RateLimitDiagnostics | null;
circuitBreakers: Record<string, CircuitBreakerDiagnostics>;
}

export interface DiagnosticsEvents {
circuitOpen: (data: { url: string; openUntil: number }) => void;
circuitClosed: (data: { url: string }) => void;
rateLimitThrottled: (data: { throttlingUntil: number }) => void;
cacheHit: (data: { key: string }) => void;
cacheMiss: (data: { key: string }) => void;
}
2 changes: 2 additions & 0 deletions src/diagnostics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './diagnostics.types';
export * from './DiagnosticsModule';
8 changes: 8 additions & 0 deletions src/http/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,4 +471,12 @@ export class HttpClient {
}
}
}

public getRateLimitStatus(): { throttlingUntil: number; currentRate: number } | null {
if (!this.tokenBucket) return null;
return {
throttlingUntil: this.tokenBucket.getThrottlingUntil(),
currentRate: this.tokenBucket.getCurrentRate(),
};
}
}
12 changes: 12 additions & 0 deletions src/http/tokenBucket.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type RateLimitConfig = {
requestsPerSecond: number;
burst?: number;
onThrottled?: (throttlingUntil: number) => void;
};

function delay(ms: number): Promise<void> {
Expand All @@ -16,7 +17,10 @@ export class TokenBucket {
private lastRefill: number;
private retryUntil: number = 0; // Timestamp until which requests are throttled

private config: RateLimitConfig;

constructor(config: RateLimitConfig) {
this.config = config;
const { requestsPerSecond, burst } = config;
this.baseRate = requestsPerSecond;
this.currentRate = requestsPerSecond;
Expand Down Expand Up @@ -66,6 +70,10 @@ export class TokenBucket {
if (this.currentRate < 0.01) this.currentRate = 0.01;
this.refillRate = this.currentRate / 1000;
this.tokens = Math.min(this.tokens, this.capacity);

if (this.config.onThrottled) {
this.config.onThrottled(this.retryUntil);
}
}

onSuccess(): void {
Expand All @@ -78,4 +86,8 @@ export class TokenBucket {
public getThrottlingUntil(): number {
return this.retryUntil > Date.now() ? this.retryUntil : 0;
}

public getCurrentRate(): number {
return this.currentRate;
}
}
2 changes: 2 additions & 0 deletions src/testing/mockClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RolesService } from '../roles/roles.service';
import { ContractClient } from '../contracts/contractClient';
import { DEFAULT_ACCESS_RESULT, DEFAULT_GUILD, DEFAULT_GUILD_CONFIG, DEFAULT_ROLE, DEFAULT_MEMBERSHIP } from './fixtures';
import type { PublicClientConfig } from '../config/sdkConfig';
import { DiagnosticsModule } from '../diagnostics/DiagnosticsModule';

/**
* Utility type to extract only the public properties and methods of a class.
Expand Down Expand Up @@ -87,6 +88,7 @@ export function createMockGuildPassClient(overrides?: MockClientOverrides): Guil
invalidateWalletCache: async () => {},
clearCache: async () => {},
getConfig: overrides?.getConfig ?? (() => ({ apiUrl: 'https://mock.guildpass.xyz' })),
diagnostics: new DiagnosticsModule(),
};

// Cast safely. TypeScript guarantees that `mockClientPublic` shapes up to the public interface.
Expand Down
Loading