Skip to content

fix(backend): add graceful degradation for RPC outages (#1162) - #1208

Open
Awosdot wants to merge 18 commits into
Junirezz:mainfrom
Awosdot:fix/issue-1162-graceful-degradation
Open

fix(backend): add graceful degradation for RPC outages (#1162)#1208
Awosdot wants to merge 18 commits into
Junirezz:mainfrom
Awosdot:fix/issue-1162-graceful-degradation

Conversation

@Awosdot

@Awosdot Awosdot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #1162

…tartup

EventPollingService.start() let a failed startup event replay abort before
the continuous polling loop was armed, so a transient Soroban RPC outage
during boot would permanently disable event polling until the process was
restarted. Catch the replay failure, log a clear warning, and continue
starting the poll loop — the next successful poll re-derives the same
missed range from the persisted cursor, so nothing is lost.
@drips-wave

drips-wave Bot commented Aug 24, 2026

Copy link
Copy Markdown

@Awosdot Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Awosdot added 17 commits August 24, 2026 21:14
npm ci failed on main with "Missing: @yieldvault/api-schemas@1.0.0 from
lock file" because the lockfile's link-package entry for the local
../packages/api-schemas file dependency was missing the lockfileVersion 3
"link" metadata npm ci requires. Regenerated via `npm install
--package-lock-only`; no dependency versions changed.
- eventOutbox.test.ts / sloMetrics.test.ts: use const for never-reassigned
  bindings (prefer-const)
- optimisticConcurrency.ts / walletAliasService.ts: rewrite while(true) retry
  loops as for(;;), which is not flagged by no-constant-condition
…erride migration

schema.prisma had three models (EventOutbox, AdminConfigChange,
FeatureFlagOverride) and a `version` column on BulkExportJob/VaultState with
no corresponding migration, so `prisma migrate deploy` against a fresh
database left those tables missing. This is what made most of the backend
test suite fail with "table does not exist" errors.

Generated the reconciling migration via `prisma migrate dev --create-only`
and dropped its one cosmetic index-rename step (dropping an index that
backs a UNIQUE constraint isn't supported directly on SQLite; the existing
autoindex already enforces the same constraint). Regenerated dev.db from
the full, now-complete migration history.
…L bug

ScopedAdminTokenStore is fully async/Prisma-backed, but the Issue Junirezz#723
tests in issues705-707-719-723.test.ts called create()/revoke()/rotate()/
list()/authenticate()/clear() without awaiting them and asserted on the
unresolved promises. Two of those tests wrapped the async create() call in
a synchronous expect(() => ...).toThrow(), which turned validation errors
into unhandled promise rejections that crashed the whole Jest worker
process. Made the describe block properly async/await throughout and
switched the two throw assertions to expect(...).rejects.toThrow().

Separately, getStalePendingTtlMs() in writeAheadAuditLog.ts treated an
explicit WAL_STALE_PENDING_TTL_MS=0 (used by a test to mean "everything is
immediately stale") as unset and fell back to the 15-minute default,
because the guard was `parsed > 0` instead of `parsed >= 0`.
Bumped axios, body-parser, brace-expansion, fast-uri, js-yaml, and
protobufjs to their patched versions within existing semver ranges
(package.json unchanged). Reduces npm audit findings from 31 (6 high) to
25 (2 high) with no code changes required.

The remaining 2 high-severity findings are all in the @opentelemetry/*
dependency chain, which has no non-breaking fix available — resolving
those needs a major-version upgrade of the OpenTelemetry SDK and is left
out of scope here since it would require re-validating the tracing
instrumentation in tracing.ts / index.ts.
… findings

Forced the last 2 high-severity npm audit findings (both rooted in
@opentelemetry/core <2.8.0) by bumping @opentelemetry/sdk-node,
exporter-trace-otlp-http, and instrumentation-http from ^0.218.0 to
^0.221.0, which pulls in a patched @opentelemetry/core transitively.
`npm audit` now reports 0 vulnerabilities.

Verified: `npm run build` and `npm run lint` are clean, all previously
affected test suites still pass, and a runtime smoke test of
initTracing()/withSpan()/shutdownTracing() from tracing.ts behaves
identically to before the bump (the only error observed is an expected
ECONNREFUSED from the exporter trying to flush spans to a real OTLP
collector, which isn't running in this environment and is unrelated to
the version change).
…ion string

schema.prisma's datasource provider is "sqlite", so its generated client
can only accept file: URLs no matter what override is passed in. The
backend-governance CI job sets DATABASE_URL to a Postgres connection
string for the separate raw `pg` pool in database.ts (which has its own
migrations under scripts/postgres-migrations.js), but prisma.ts was also
reading DATABASE_URL and passing it straight through to PrismaClient's
datasource override, which Prisma rejects outright ("the URL must start
with the protocol file:") — crashing every Prisma-backed query in that
job before a single test could run.

buildDatasourceUrl() now falls back to the schema-declared sqlite file
whenever DATABASE_URL isn't itself a file: URL, instead of forwarding an
override the generated client can never accept.

fix(ci): pin cargo-audit to a version compatible with the pinned Rust toolchain

rust-toolchain.toml pins the workspace to rustc 1.85.0 (for reproducible
Soroban/WASM contract builds), but `cargo install cargo-audit` was
installing the latest release (0.22.2), which requires rustc 1.88+.
Pinned to cargo-audit 0.22.1, which the error message itself confirms
supports 1.85, instead of bumping the toolchain pin and risking a change
in contract build output.
…safety false positive

The EventOutbox migration's auto-generated SQLite table rebuild (drop +
recreate) for adding a version column to BulkExportJob/VaultState tripped
the repo's migration-safety check, which hard-bans any DROP TABLE/COLUMN/
INDEX pattern with no annotation opt-out. Rewrote it as plain ALTER TABLE
ADD COLUMN statements (fully supported by SQLite for a column with a
constant DEFAULT), which achieves the same schema without ever dropping
the table or its existing indexes.

Also fixed a real false positive in the migration-safety script itself:
its ADD COLUMN NOT NULL / no DEFAULT check anchored the regex match at
"NOT NULL", so a trailing "DEFAULT x" (Prisma's own convention — "TYPE
NOT NULL DEFAULT x") was invisible to it, incorrectly flagging an
already-safe pre-existing migration (webhook_verification) as risky.
Widened the match to the whole ADD COLUMN statement so DEFAULT is
detected regardless of which side of NOT NULL it's on.

Regenerated dev.db from the corrected migration.
… test provisions

DatabaseManager's no-args constructor always built a real PostgresDatabasePool,
falling back to a hardcoded postgres://postgres:postgres@localhost:5432/yieldvault
connection string when DATABASE_URL was unset. No test suite runs a real
Postgres at that address, so every isHealthy() check against the singleton
`db` export failed for a reason entirely unrelated to what a given test was
actually exercising — most visibly, /health always reported
databasePrimary/databaseReplica as "down" and returned 503.

Added NoopDatabasePool (resolves queries to empty results, reports healthy)
and use it instead of a real Postgres pool when NODE_ENV=test and
DATABASE_URL is unset — mirroring the existing fallback pattern this
codebase already uses for Redis and the deposits rate limiter. Real
production/dev behavior (DATABASE_URL set, or NODE_ENV=production) is
unchanged.
VaultSummaryResponseSchema declared totalAssets/totalShares as z.number()
and had no field for sharePrice, but the real GET /api/v1/vault/summary
handler (buildVaultSummaryResponseFromDb in index.ts) returns both as
Decimal-backed strings — this API's established convention for money
fields, to avoid floating-point precision loss (see TransactionItemSchema.
amount in the same file, already z.string()) — and always includes
sharePrice. HealthResponseSchema was similarly missing lastIndexedLedger,
which GET /health has always returned.

Both schemas are .strict(), so every real response failed validation:
missing fields as "unrecognized keys", and totalAssets/totalShares as
wrong-type errors. Fixed the schemas, the fixture payloads in
issues711.test.ts that encoded the same stale shape, and the inline
assertions in openApiContractTests.test.ts (numeric-finite check now
Number()-coerces the string fields; the additionalProperties allowlist
now includes sharePrice). Regenerated the committed schema-snapshots/*.json
files to match.
… summary

- index.ts had no catch-all 404 handler, so an unmatched route fell through
  to Express's default plain-text 404 instead of this API's JSON error
  format. Added one as the final middleware.
- buildImpersonatedVaultState() built its "summary" field from the old
  buildVaultSummaryResponse() mock (always zeroed numbers) instead of the
  DB-backed buildVaultSummaryResponseFromDb() the real GET
  /api/v1/vault/summary route serves, so an admin impersonating a wallet
  saw stale/wrong balances instead of what that wallet's own dashboard
  shows. Removed the now-unused mock function.
- transactionEndpoints.ts's GET /api/v1/transactions handler called the
  async buildTransactionsResponse() without awaiting it in the
  no-walletAddress branch, so res.json() serialized the pending Promise —
  which has no enumerable own properties — as "{}", and the DateRangeParseError
  catch below it could never actually catch anything thrown inside that
  promise.
…d25519 keys

VALID_TEST_WALLET / SECOND_TEST_WALLET / THIRD_TEST_WALLET (and the
identical MOCK_WALLET_ADDRESS in listEndpoints.ts, backing MOCK_TRANSACTIONS)
were hand-typed, regex-shaped strings — correct character set, but not a
real Ed25519 public key, so they failed StrKey.isValidEd25519PublicKey.
Endpoints validated only via the looser @yieldvault/api-schemas regex
(e.g. deposits) tolerated them; anything using the stricter check
(walletAddressSchema in middleware/validate.ts — login, nonce, signed
actions) rejected every test using these constants outright. Replaced all
four with real keypair public keys, keeping listEndpoints.ts's copy in
sync with setup.ts's since MOCK_TRANSACTIONS's wallet ownership is
asserted against VALID_TEST_WALLET in several tests.

fix(test-env): relax rate-limit/adaptive-throttle defaults broadly, restore
true values where a test specifically exercises them

setup.ts already relaxed RATE_LIMIT_ADMIN_MAX/RATE_LIMIT_WRITES_MAX for
tests; extended the same treatment to RATE_LIMIT_AUTH_MAX,
DEPOSITS_RATE_LIMIT_MAX, RATE_LIMIT_READS_MAX, SUMMARY_RATE_LIMIT_MAX,
API_RATE_LIMIT_MAX_REQUESTS, and ADAPTIVE_THROTTLE_SCORE_THRESHOLD — any
integration test file exercising real auth/read/write/deposit routes
across several `it` blocks, or several validation-error responses, could
exhaust these production-tight defaults or trip the adaptive-throttle
block well before its assertions were about rate limiting at all.

Two dedicated suites test these mechanisms at their real defaults and
restore them locally (before importing the modules that read them once at
load time): rateLimiter.test.ts's "depositsLimiter on vault deposit/
withdrawal routes" block and rateLimiter.auth_transfer.test.ts (both need
the true DEPOSITS_RATE_LIMIT_MAX=10), and api.test.ts (has its own
adaptive-throttle escalation test and already resets that middleware's
state per test, so needs the true ADAPTIVE_THROTTLE_SCORE_THRESHOLD=6
restored rather than the relaxed global default).
…e in tests

webhookInputValidation.test.ts sent its admin API key via a plain
'x-api-key' header on every request; validateApiKey only recognizes
'Authorization: ApiKey <key>' (as every other admin test file already
does), so every request in this file was rejected with 401 regardless of
what it was actually testing.

rbac.test.ts registered webhooks with eventTypes: ['transaction.created'],
which isn't one of the two valid event types (transaction.deposit.created,
transaction.withdrawal.created — see WEBHOOK_EVENT_TYPES), so registration
itself failed and later assertions dereferenced the (absent)
created.body.endpoint.id.
…mock, stale regex)

- adminFeatures.test.ts: the deposit test's walletAddress had lowercase
  letters, which VaultDepositBodySchema's regex rejects (Stellar addresses
  are uppercase base32); and its webhook-delivery assertion raced the
  outbox's background poller with a fixed 30ms wait instead of draining it
  explicitly via eventOutboxService.processOutbox().
- withdrawalRecoveryEndpoint.test.ts's deposit test 500'd because
  referralService.recordDeposit() (called on every deposit) resolves the
  wallet's canonical identity and opens its own Prisma transaction for
  referral bookkeeping, neither of which this file's minimal Prisma mock
  supports — and neither of which this suite (withdrawal saga recovery) is
  testing. Mocked referralService directly instead of expanding the Prisma
  mock's surface for an unrelated subsystem.
- jobGovernanceMetrics.test.ts's three metric-matching regexes anchored
  the closing brace immediately after job_name="...", but the actual
  Prometheus output has an additional app="yieldvault-backend" default
  label (register.setDefaultLabels) before the brace closes. Widened the
  regexes to tolerate any additional labels.
backend-governance.yml never installed/built packages/api-schemas before
running backend commands, so every step that imports @yieldvault/api-schemas
(most of the backend, transitively via middleware/validate.ts) failed with
"Cannot find module 'zod'" — that package's own dependency, never
installed because its package.json/package-lock.json were never touched.
Added the same "Build shared API schemas" step the other two workflows
(Monorepo CI, Dependency Vulnerability Audit) already have.

The cargo-audit 0.22.1 pin (previous commit) still failed to install: an
unlocked `cargo install` re-resolves that crate's dependency tree against
whatever's newest on crates.io today, and several of those transitive
deps have since raised their own MSRV past the workspace's pinned rustc
1.85.0 — the exact error message suggested the fix. Added --locked to
install against the dependency versions actually validated for 0.22.1's
release, instead of the latest crates.io allows.
fetchCurrentApy() queried a table named "vault_metrics", but the actual
raw-SQL migration (migrations/001_initial_storage.sql) creates
"vault_metrics_snapshots" — every other query in this codebase referencing
that table already uses the correct name (see
scripts/postgres-migrations.js's drift check, which explicitly checks for
"vault_metrics_snapshots"). Against a real Postgres database (e.g. the
backend-governance CI job), this raised "relation vault_metrics does not
exist" and crashed the entire APY snapshot job and every endpoint that
depends on it, even though the query's own result is discarded (this
function returns a synthetic value pending a real Soroban RPC integration).
…ed stable one

The "Set up Rust nightly (cargo-fuzz)" step only installs the nightly
toolchain as an available option — it doesn't change the active default,
which stays pinned to rust-toolchain.toml's 1.85.0 (rustup directory
overrides don't apply just because a toolchain was installed). The very
next step, "Run vault share-price fuzz", already accounts for this by
invoking `cargo +nightly fuzz run ...` explicitly, but "Install cargo-fuzz"
ran plain `cargo install`, resolving under 1.85.0 and failing outright:
cargo-fuzz 0.13.2's own locked dependencies (cargo-platform, cargo_metadata)
require rustc 1.86–1.91. Added the same `+nightly` override to the install
step so it builds under the toolchain it's actually meant for.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend: Add graceful degradation for external dependency outages

1 participant