Where: src/lib/stellar.ts:
/** Re-exported so registry/cron can catch it for queue-deferral logic. */
export class RpcDegradedError extends Error {
constructor(msg = "RPC is degraded") {
super(msg);
this.name = "RpcDegradedError";
}
}
...
export function withRpcConnection<T>(fn) {
return rpcBreaker.execute(
() => rpcPool.withConnection(fn),
async () => { throw new RpcDegradedError("Stellar RPC circuit is OPEN – request rejected"); },
);
}
and, separately, src/lib/registry.ts:
export class RpcDegradedError extends Error {
constructor(message: string) {
super(message);
this.name = "RpcDegradedError";
}
}
What's wrong: despite the comment in stellar.ts explicitly saying
"Re-exported so registry/cron can catch it for queue-deferral logic",
registry.ts does not re-export stellar.ts's class — it declares an
entirely separate class RpcDegradedError extends Error { ... } of its
own (different constructor signature too: registry.ts's requires a
message: string with no default, stellar.ts's defaults to "RPC is degraded"). These are two distinct classes with the same name and no
inheritance relationship between them.
withRpcConnection (called by both updateImpactScore and
getTotalProjects in registry.ts) throws stellar.ts's
RpcDegradedError when the circuit breaker is open. But every consumer
that wants to detect this condition imports registry.ts's class:
// src/lib/scoreService.ts
import { updateImpactScore, RpcDegradedError } from "./registry";
...
} catch (updateErr) {
if (updateErr instanceof RpcDegradedError) { // checks against registry.ts's class
return { status: "deferred", ... };
}
throw updateErr;
}
(the identical pattern also appears in src/routes/batch.ts, which
imports RpcDegradedError from ../lib/registry too). Because
updateErr is actually an instance of stellar.ts's RpcDegradedError
— a completely different class/prototype chain — instanceof returns
false even though the error's name string and message text look
identical. The if branch is dead: it can never be entered through this
path.
Impact: this breaks graceful RPC-outage handling at its core. When the
Stellar RPC circuit breaker opens (rpcBreaker — an extended outage,
exactly the scenario the whole circuit-breaker + tx-queue system exists
for), updateScoreForProject() never recognizes the resulting error as
"deferred" — it falls through to throw updateErr, which
scoreUpdateCron.ts/routes/admin.ts/routes/batch.ts all treat as a
hard per-project failure (markFailed, failureCount++) instead of
enqueuing it via enqueue() in lib/tx-queue.ts for the retry cron to
pick up later. During an RPC outage, every project update is now recorded
as a failure and never gets a chance to be automatically retried once the
RPC recovers — the deferred-retry pathway this system was built around is
unreachable.
Suggested fix: delete registry.ts's duplicate class declaration and
have it import { RpcDegradedError } from "./stellar" (re-exporting it if
other modules need to import it from registry.ts for convenience), so
there is exactly one RpcDegradedError class and instanceof checks
against it actually work.
Where:
src/lib/stellar.ts:and, separately,
src/lib/registry.ts:What's wrong: despite the comment in
stellar.tsexplicitly saying"Re-exported so registry/cron can catch it for queue-deferral logic",
registry.tsdoes not re-exportstellar.ts's class — it declares anentirely separate
class RpcDegradedError extends Error { ... }of itsown (different constructor signature too:
registry.ts's requires amessage: stringwith no default,stellar.ts's defaults to"RPC is degraded"). These are two distinct classes with the same name and noinheritance relationship between them.
withRpcConnection(called by bothupdateImpactScoreandgetTotalProjectsinregistry.ts) throwsstellar.ts'sRpcDegradedErrorwhen the circuit breaker is open. But every consumerthat wants to detect this condition imports
registry.ts's class:(the identical pattern also appears in
src/routes/batch.ts, whichimports
RpcDegradedErrorfrom../lib/registrytoo). BecauseupdateErris actually an instance ofstellar.ts'sRpcDegradedError— a completely different class/prototype chain —
instanceofreturnsfalseeven though the error'snamestring and message text lookidentical. The
ifbranch is dead: it can never be entered through thispath.
Impact: this breaks graceful RPC-outage handling at its core. When the
Stellar RPC circuit breaker opens (
rpcBreaker— an extended outage,exactly the scenario the whole circuit-breaker + tx-queue system exists
for),
updateScoreForProject()never recognizes the resulting error as"deferred" — it falls through to
throw updateErr, whichscoreUpdateCron.ts/routes/admin.ts/routes/batch.tsall treat as ahard per-project failure (
markFailed,failureCount++) instead ofenqueuing it via
enqueue()inlib/tx-queue.tsfor the retry cron topick up later. During an RPC outage, every project update is now recorded
as a failure and never gets a chance to be automatically retried once the
RPC recovers — the deferred-retry pathway this system was built around is
unreachable.
Suggested fix: delete
registry.ts's duplicate class declaration andhave it
import { RpcDegradedError } from "./stellar"(re-exporting it ifother modules need to import it from
registry.tsfor convenience), sothere is exactly one
RpcDegradedErrorclass andinstanceofchecksagainst it actually work.