Skip to content

Commit 77016d5

Browse files
authored
Merge pull request #156 from mmotunrayo/feat/circuit-breaker-apis
feat: implement circuit breaker for Google Maps and Stellar RPC calls (#144)
2 parents 665a2b7 + c39bf2a commit 77016d5

11 files changed

Lines changed: 810 additions & 186 deletions

File tree

.env.example

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,25 @@ ESCROW_CONTRACT_ID=
6464
# Default: escrow_funded
6565
ESCROW_FUNDED_EVENT_TOPIC=escrow_funded
6666

67-
# ─── Idempotency ────────────────────────────────────────────────────────────────
68-
# Redis connection URL used by the idempotency middleware.
69-
# When omitted the app falls back to a MongoDB-backed idempotency store.
70-
# Format: redis://<user>:<password>@<host>:<port>/<db>
71-
# Example (local): redis://localhost:6379
72-
# Example (TLS): rediss://:<password>@<host>:6380
73-
REDIS_URL=redis://localhost:6379
74-
75-
# How long (seconds) an idempotency key is retained. Default: 86400 (24 h).
76-
IDEMPOTENCY_TTL_SECONDS=86400
67+
# ─── Circuit Breakers ────────────────────────────────────────────────────────────
68+
# All thresholds follow opossum's semantics. Tune per environment; defaults below
69+
# are conservative values suitable for production.
70+
71+
# Google Maps Directions API circuit breaker
72+
# % of calls that must fail within the rolling window before the circuit opens.
73+
CB_GOOGLE_MAPS_ERROR_THRESHOLD_PERCENTAGE=50
74+
# Rolling window duration (ms) used to compute the error rate.
75+
CB_GOOGLE_MAPS_ROLLING_WINDOW_MS=30000
76+
# How long (ms) the circuit stays OPEN before allowing a single test call.
77+
CB_GOOGLE_MAPS_RESET_TIMEOUT_MS=60000
78+
# Minimum calls in the window before the breaker is allowed to open.
79+
CB_GOOGLE_MAPS_VOLUME_THRESHOLD=5
80+
# Per-call timeout (ms). Calls that exceed this are counted as failures.
81+
CB_GOOGLE_MAPS_TIMEOUT_MS=10000
82+
83+
# Stellar / Soroban RPC circuit breaker
84+
CB_SOROBAN_ERROR_THRESHOLD_PERCENTAGE=50
85+
CB_SOROBAN_ROLLING_WINDOW_MS=30000
86+
CB_SOROBAN_RESET_TIMEOUT_MS=60000
87+
CB_SOROBAN_VOLUME_THRESHOLD=3
88+
CB_SOROBAN_TIMEOUT_MS=15000

package-lock.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"mongoose": "^7.6.3",
3030
"multer": "^2.2.0",
3131
"node-cron": "^3.0.3",
32+
"opossum": "8.1.2",
3233
"socket.io": "4.7.2",
3334
"swagger-jsdoc": "^6.3.0",
3435
"swagger-ui-express": "^5.0.1",
@@ -51,6 +52,7 @@
5152
"@types/multer": "^2.2.0",
5253
"@types/node": "^20.10.0",
5354
"@types/node-cron": "^3.0.11",
55+
"@types/opossum": "8.1.4",
5456
"@types/socket.io": "3.0.2",
5557
"@types/supertest": "^7.2.1",
5658
"@types/swagger-jsdoc": "^6.0.4",

src/blockchain/soroban.service.ts

Lines changed: 124 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,22 @@
11
import { rpc as StellarRpc } from '@stellar/stellar-sdk';
2+
import CircuitBreaker from 'opossum';
23
import logger from '../config/logger';
34
import { sorobanRpcClient, stellarConfig } from '../config/stellar';
45
import env from '../config/env';
56
import { withRetry, RetryOptions } from '../utils/rpcRetry';
7+
import { createCircuitBreaker, fireWithBreaker } from '../utils/circuitBreaker';
68

79
/**
810
* Result returned by a successful connectivity check.
911
*/
1012
export interface ConnectivityCheckResult {
11-
/** Whether the RPC node is reachable and healthy. */
1213
connected: boolean;
13-
/** Human-readable network alias. */
1414
network: string;
15-
/** Network passphrase used. */
1615
networkPassphrase: string;
17-
/** RPC endpoint that was queried. */
1816
rpcUrl: string;
19-
/** Health status string returned by the node (e.g. "healthy"). */
2017
status: string;
21-
/** Latest ledger number at time of check. */
2218
latestLedger: number;
23-
/** ISO timestamp of when the check was performed. */
2419
checkedAt: string;
25-
/** Round-trip latency in milliseconds. */
2620
latencyMs: number;
2721
}
2822

@@ -37,27 +31,63 @@ export interface ConnectivityCheckError {
3731
error: string;
3832
}
3933

34+
/**
35+
* Degraded ledger response returned when the Soroban circuit is OPEN.
36+
* Controllers should treat `degraded: true` as a signal to surface a 503.
37+
*/
38+
export interface DegradedLedgerResult {
39+
degraded: true;
40+
reason: string;
41+
}
42+
4043
/**
4144
* SorobanService provides the business-logic layer for all Stellar / Soroban
42-
* RPC interactions.
45+
* RPC interactions, protected by a shared circuit breaker.
46+
*
47+
* Circuit-breaker behaviour:
48+
* - CLOSED — RPC calls are executed normally (with retry).
49+
* - OPEN — calls are short-circuited; fallback values are returned
50+
* immediately so the API stays responsive.
51+
* - HALF-OPEN — one probe call is allowed through to test recovery.
4352
*
44-
* Responsibilities:
45-
* - Perform a live connectivity check against the configured RPC node.
46-
* - Surface health, network, and ledger data for API responses.
47-
* - Abstract the raw SDK client behind a typed interface so higher layers
48-
* (controllers, other services) are decoupled from the SDK.
53+
* The circuit breaker wraps individual RPC calls rather than the service
54+
* methods themselves, so that `checkConnectivity()` — which already handles
55+
* its own errors — is not double-wrapped.
4956
*/
5057
export class SorobanService {
5158
private readonly client: StellarRpc.Server;
5259

60+
/**
61+
* Shared circuit breaker for all Soroban RPC operations.
62+
* Typed as `CircuitBreaker<[() => Promise<unknown>], unknown>` because we
63+
* use `fireWithBreaker` to pass a different action on each call.
64+
*/
65+
private readonly breaker: CircuitBreaker<[() => Promise<unknown>], unknown>;
66+
5367
constructor(client: StellarRpc.Server = sorobanRpcClient) {
5468
this.client = client;
69+
70+
this.breaker = createCircuitBreaker<[() => Promise<unknown>], unknown>(
71+
{
72+
name: 'soroban-rpc',
73+
errorThresholdPercentage: env.CB_SOROBAN_ERROR_THRESHOLD_PERCENTAGE,
74+
rollingWindowMs: env.CB_SOROBAN_ROLLING_WINDOW_MS,
75+
resetTimeoutMs: env.CB_SOROBAN_RESET_TIMEOUT_MS,
76+
volumeThreshold: env.CB_SOROBAN_VOLUME_THRESHOLD,
77+
timeoutMs: env.CB_SOROBAN_TIMEOUT_MS,
78+
},
79+
// Fallback: return a sentinel so callers know the result is degraded.
80+
(): DegradedLedgerResult => ({
81+
degraded: true,
82+
reason:
83+
'Soroban RPC circuit is OPEN — the node is temporarily unreachable. ' +
84+
'The system will automatically retry when the circuit recovers.',
85+
}),
86+
);
5587
}
5688

57-
/**
58-
* Retry configuration derived from environment settings, shared by every
59-
* RPC call this service makes.
60-
*/
89+
// ── Retry configuration ──────────────────────────────────────────────────────
90+
6191
private get retryOptions(): Pick<RetryOptions, 'maxAttempts' | 'baseDelayMs' | 'maxDelayMs'> {
6292
return {
6393
maxAttempts: env.SOROBAN_RPC_MAX_RETRIES,
@@ -67,22 +97,33 @@ export class SorobanService {
6797
}
6898

6999
/**
70-
* Wrap a Soroban RPC call with exponential-backoff retry so transient
71-
* failures (rate limiting, temporary node outages) are absorbed instead
72-
* of propagating on the first failure.
100+
* Wrap a Soroban RPC call with:
101+
* 1. Exponential-backoff retry (absorbs transient failures / rate limits).
102+
* 2. Circuit breaker (trips when sustained failures exceed the threshold).
73103
*/
74-
private callWithRetry<T>(operationName: string, fn: () => Promise<T>): Promise<T> {
75-
return withRetry(fn, { ...this.retryOptions, operationName });
104+
private async callWithRetryAndBreaker<T>(
105+
operationName: string,
106+
fn: () => Promise<T>,
107+
): Promise<T | DegradedLedgerResult> {
108+
const retryWrapped = (): Promise<T> =>
109+
withRetry(fn, { ...this.retryOptions, operationName });
110+
111+
return fireWithBreaker(
112+
this.breaker as CircuitBreaker<[() => Promise<T>], T | DegradedLedgerResult>,
113+
(action: () => Promise<T>) => action(),
114+
retryWrapped,
115+
);
76116
}
77117

118+
// ── Public API ───────────────────────────────────────────────────────────────
119+
78120
/**
79-
* Perform a connectivity check against the Soroban RPC node.
121+
* Perform a live connectivity check against the Soroban RPC node.
80122
*
81-
* Calls `getHealth()` and `getLatestLedger()` in parallel. Both must
82-
* succeed for the check to be considered healthy.
83-
*
84-
* @returns A `ConnectivityCheckResult` on success, or a
85-
* `ConnectivityCheckError` on failure.
123+
* Calls `getHealth()` and `getLatestLedger()` in parallel. The circuit
124+
* breaker wraps each call individually so a single slow call doesn't block
125+
* both. This method never throws — it returns a typed error object on
126+
* failure so callers can decide how to respond.
86127
*/
87128
public async checkConnectivity(): Promise<ConnectivityCheckResult | ConnectivityCheckError> {
88129
const checkedAt = new Date().toISOString();
@@ -93,14 +134,37 @@ export class SorobanService {
93134
);
94135

95136
try {
96-
const [health, ledger] = await Promise.all([
97-
this.callWithRetry('getHealth', () => this.client.getHealth()),
98-
this.callWithRetry('getLatestLedger', () => this.client.getLatestLedger()),
137+
const [healthResult, ledgerResult] = await Promise.all([
138+
this.callWithRetryAndBreaker('getHealth', () => this.client.getHealth()),
139+
this.callWithRetryAndBreaker('getLatestLedger', () => this.client.getLatestLedger()),
99140
]);
100141

142+
// If either call returned a degraded sentinel the circuit is open.
143+
if (
144+
(healthResult as DegradedLedgerResult).degraded ||
145+
(ledgerResult as DegradedLedgerResult).degraded
146+
) {
147+
const latencyMs = Date.now() - start;
148+
logger.warn(`[Soroban] Connectivity check degraded — circuit is OPEN`);
149+
return {
150+
connected: false,
151+
network: stellarConfig.network,
152+
rpcUrl: stellarConfig.rpcUrl,
153+
checkedAt,
154+
error: 'Soroban RPC circuit breaker is OPEN — node temporarily unreachable',
155+
};
156+
}
157+
158+
const health = healthResult as StellarRpc.Api.GetHealthResponse;
159+
const ledger = ledgerResult as StellarRpc.Api.GetLatestLedgerResponse;
101160
const latencyMs = Date.now() - start;
102161

103-
const result: ConnectivityCheckResult = {
162+
logger.info(
163+
`[Soroban] Connectivity OK — network=${stellarConfig.network} ` +
164+
`ledger=${ledger.sequence} latency=${latencyMs}ms`,
165+
);
166+
167+
return {
104168
connected: true,
105169
network: stellarConfig.network,
106170
networkPassphrase: stellarConfig.networkPassphrase,
@@ -110,13 +174,6 @@ export class SorobanService {
110174
checkedAt,
111175
latencyMs,
112176
};
113-
114-
logger.info(
115-
`[Soroban] Connectivity OK — network=${stellarConfig.network} ` +
116-
`ledger=${ledger.sequence} latency=${latencyMs}ms`,
117-
);
118-
119-
return result;
120177
} catch (err) {
121178
const latencyMs = Date.now() - start;
122179
const message = err instanceof Error ? err.message : 'Unknown error';
@@ -126,36 +183,52 @@ export class SorobanService {
126183
`latency=${latencyMs}ms error="${message}"`,
127184
);
128185

129-
const errorResult: ConnectivityCheckError = {
186+
return {
130187
connected: false,
131188
network: stellarConfig.network,
132189
rpcUrl: stellarConfig.rpcUrl,
133190
checkedAt,
134191
error: message,
135192
};
136-
137-
return errorResult;
138193
}
139194
}
140195

141196
/**
142197
* Fetch the latest ledger sequence number from the RPC node.
143198
*
144-
* @returns The ledger sequence number.
145-
* @throws If the RPC call fails.
199+
* @returns The ledger sequence number, or a {@link DegradedLedgerResult}
200+
* when the circuit is OPEN.
201+
* @throws When the RPC call fails and the circuit breaker's fallback itself
202+
* throws (should not happen in practice).
146203
*/
147-
public async getLatestLedger(): Promise<number> {
148-
const ledger = await this.callWithRetry('getLatestLedger', () => this.client.getLatestLedger());
149-
return ledger.sequence;
204+
public async getLatestLedger(): Promise<number | DegradedLedgerResult> {
205+
const result = await this.callWithRetryAndBreaker(
206+
'getLatestLedger',
207+
() => this.client.getLatestLedger(),
208+
);
209+
210+
if ((result as DegradedLedgerResult).degraded) {
211+
return result as DegradedLedgerResult;
212+
}
213+
214+
return (result as StellarRpc.Api.GetLatestLedgerResponse).sequence;
150215
}
151216

152217
/**
153218
* Fetch network information (passphrase, protocol version) from the RPC node.
154219
*
155-
* @returns The raw `getNetwork` response from the SDK.
220+
* @returns The raw `getNetwork` response, or a {@link DegradedLedgerResult}
221+
* when the circuit is OPEN.
156222
*/
157-
public async getNetworkInfo(): Promise<StellarRpc.Api.GetNetworkResponse> {
158-
return this.callWithRetry('getNetwork', () => this.client.getNetwork());
223+
public async getNetworkInfo(): Promise<
224+
StellarRpc.Api.GetNetworkResponse | DegradedLedgerResult
225+
> {
226+
const result = await this.callWithRetryAndBreaker(
227+
'getNetwork',
228+
() => this.client.getNetwork(),
229+
);
230+
231+
return result as StellarRpc.Api.GetNetworkResponse | DegradedLedgerResult;
159232
}
160233
}
161234

0 commit comments

Comments
 (0)