Skip to content

feat: add retry with exponential backoff to Soroban RPC client - #309

Open
rilwanubala wants to merge 1 commit into
WHEELBACK:mainfrom
rilwanubala:feature/add-retry-with-exponential-backoff-to-the-soroban-rpc-client
Open

feat: add retry with exponential backoff to Soroban RPC client#309
rilwanubala wants to merge 1 commit into
WHEELBACK:mainfrom
rilwanubala:feature/add-retry-with-exponential-backoff-to-the-soroban-rpc-client

Conversation

@rilwanubala

Copy link
Copy Markdown

Summary

Closes #199

Adds retry with exponential backoff to all Soroban RPC calls in backend/src/soroban.rs. Transient failures (timeouts, connection resets, HTTP 5xx) are automatically retried up to 3 times with doubling delays capped at 30 seconds total. Non-transient errors (4xx, contract-level NOT_FOUND / UNAUTHORIZED / NOT_PAID) are surfaced immediately without retrying.

What changed

Core implementation (backend/src/soroban.rs)

  • is_transient(err) — classifies errors as retryable vs. permanent
  • with_retry(op) — generic async retry helper (max 3 retries, 200 ms → 400 ms → 800 ms backoff, 30 s total cap)
  • rpc_post(req) — internal HTTP helper wrapping every RPC call with retry; treats HTTP 5xx as transient
  • check_horizon_health() — also wrapped with retry for 5xx responses

Bug fixes (pre-existing compile/test failures)

  • health.rs: Fix incompatible if/else return types in mock server + missing into_make_service() in axum::serve
  • cancel.rs, refund.rs: SorobanClient::new called with 2 args instead of 3
  • pay/cancel/refund tests: Accept 415 or 422 for missing-body requests (axum-test v14 sends no Content-Type)
  • routes/mod.rs: Add missing cancel module export
  • main.rs: Add missing cancel_invoice / refund_invoice imports
  • Rename routes.rsroutes_auth_legacy.rs to remove Rust E0761 ambiguity with routes/mod.rs

Tests

All 20 tests pass (cargo test):

running 20 tests
test soroban::tests::transient_false_for_not_found ... ok
test soroban::tests::transient_false_for_unauthorized ... ok
test soroban::tests::transient_false_for_not_paid ... ok
test soroban::tests::transient_false_for_empty_rpc_result ... ok
test soroban::tests::transient_true_for_5xx_message ... ok
test soroban::tests::transient_true_for_timeout_message ... ok
test soroban::tests::transient_true_for_connection_message ... ok
test soroban::tests::retries_on_5xx_and_eventually_succeeds ... ok
test soroban::tests::gives_up_after_max_retries_on_persistent_5xx ... ok
test soroban::tests::does_not_retry_on_4xx ... ok
test soroban::tests::get_invoice_retries_on_5xx_and_succeeds ... ok
test soroban::tests::not_found_error_is_not_retried ... ok
test routes::cancel::tests::test_cancel_invoice_missing_body_returns_422 ... ok
test routes::cancel::tests::test_cancel_invoice_unreachable_rpc_returns_error ... ok
test routes::health::tests::returns_200_when_all_dependencies_are_healthy ... ok
test routes::health::tests::returns_503_when_any_dependency_is_degraded ... ok
test routes::pay::tests::test_pay_invoice_missing_body_returns_422 ... ok
test routes::pay::tests::test_pay_invoice_unreachable_rpc_returns_5xx_or_404 ... ok
test routes::refund::tests::test_refund_invoice_missing_body_returns_422 ... ok
test routes::refund::tests::test_refund_invoice_unreachable_rpc_returns_error ... ok

test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured

New tests added (soroban::tests)

Test What it verifies
transient_false_for_not_found NOT_FOUND is not retried
transient_false_for_unauthorized UNAUTHORIZED is not retried
transient_false_for_not_paid NOT_PAID is not retried
transient_false_for_empty_rpc_result Empty result is not retried
transient_true_for_5xx_message 5xx message is classified transient
transient_true_for_timeout_message timeout message is classified transient
transient_true_for_connection_message connection error is classified transient
retries_on_5xx_and_eventually_succeeds Mock server: 2×503 then success → 3 total requests
gives_up_after_max_retries_on_persistent_5xx Mock server: all 503s → exactly MAX_RETRIES+1 requests
does_not_retry_on_4xx Mock server: 404 → exactly 1 request (no retry)
get_invoice_retries_on_5xx_and_succeeds get_invoice retries correctly
not_found_error_is_not_retried Contract NOT_FOUND → single attempt only

Retry behaviour

Condition Behaviour
HTTP 5xx from RPC node Retry (up to 3×, backoff 200→400→800 ms)
Connection refused / timeout Retry
HTTP 4xx from RPC node No retry
Contract NOT_FOUND / UNAUTHORIZED / NOT_PAID No retry
Empty RPC result No retry
Total retry budget exceeded (>30 s) Stop retrying, surface error

Add retry with exponential backoff to all Soroban RPC calls in
backend/src/soroban.rs.

Changes:
- Add is_transient() to classify errors: retries on timeouts, connection
  failures and HTTP 5xx; never retries on 4xx or contract-level errors
  (NOT_FOUND, UNAUTHORIZED, NOT_PAID)
- Add with_retry() generic async helper with exponential backoff:
  up to 3 retries, initial delay 200ms doubling each attempt, capped
  at 30s total wait
- Add rpc_post() internal helper that wraps every RPC HTTP call with
  the retry wrapper and treats HTTP 5xx as transient
- Wrap check_horizon_health() with retry for 5xx responses
- Add 10 tests in soroban::tests covering:
  * is_transient() classification (unit tests, no network)
  * retries_on_5xx_and_eventually_succeeds (mock server, 2 failures + 1 success)
  * gives_up_after_max_retries_on_persistent_5xx (all attempts fail)
  * does_not_retry_on_4xx (only 1 attempt made)
  * get_invoice_retries_on_5xx_and_succeeds
  * not_found_error_is_not_retried

Bug fixes (pre-existing compile/test failures):
- Fix health.rs mock: incompatible if/else return types + missing
  into_make_service() in axum::serve calls
- Fix cancel.rs, refund.rs: SorobanClient::new called with 2 args
  instead of 3 (missing horizon_url)
- Fix pay/cancel/refund tests: accept 415 OR 422 for missing-body
  requests (axum-test v14 sends no Content-Type → server returns 415)
- Add cancel module to routes/mod.rs
- Fix main.rs imports (cancel_invoice, refund_invoice were unresolved)
- Rename routes.rs → routes_auth_legacy.rs to remove ambiguity with
  routes/mod.rs (Rust compiler error E0761)

All 20 tests pass.

Closes WHEELBACK#199
@drips-wave

drips-wave Bot commented Jul 28, 2026

Copy link
Copy Markdown

@rilwanubala 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

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.

Add retry with exponential backoff to the Soroban RPC client

1 participant