Skip to content

Commit 672ee6d

Browse files
authored
feat: Add insufficient fee and TRY_AGAIN_LATER outcome metrics for Stellar transactions (#702)
* feat: Add additional stellar metrics * chore: Test * feat: Add wiremock setup * chore: PR feedback
1 parent 818fbcb commit 672ee6d

18 files changed

Lines changed: 739 additions & 130 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
---
2+
name: wiremock-test
3+
description: Manage WireMock proxy for RPC testing. Starts the proxy, arms scenarios to simulate transient errors (timeouts, 500s, connection resets, and network-specific errors like Stellar TRY_AGAIN_LATER), and checks metrics. Use to verify relayer behavior under failure conditions.
4+
allowed-tools: Bash, Read, Grep, Glob, Agent
5+
argument-hint: "<scenario> | setup <network> [rpc-url] | teardown | status | check-metrics"
6+
---
7+
8+
# WireMock RPC Test Proxy
9+
10+
You manage a WireMock proxy that sits between the relayer and an RPC endpoint. It forwards traffic normally, but can inject mock error responses on demand.
11+
12+
## Project paths
13+
14+
- Docker compose: `testing/wiremock/docker-compose.yaml`
15+
- Generic mappings: `testing/wiremock/mappings/generic/` (work with any JSON-RPC network)
16+
- Stellar mappings: `testing/wiremock/mappings/stellar/`
17+
- Future EVM mappings: `testing/wiremock/mappings/evm/` (not yet created)
18+
- Helper scripts: `testing/wiremock/scripts/`
19+
20+
## Available scenarios
21+
22+
### Generic (any network)
23+
24+
| Scenario name | Shorthand | What it does |
25+
|---|---|---|
26+
| `rpc-timeout` | `timeout` | 30s delay on next RPC call (any method) |
27+
| `rpc-500` | `500` | HTTP 500 on next RPC call |
28+
| `rpc-connection-reset` | `reset` | TCP connection reset on next RPC call |
29+
30+
### Stellar-specific
31+
32+
| Scenario name | Shorthand | What it does |
33+
|---|---|---|
34+
| `stellar-try-again-later` | `tal`, `try-again-later` | Returns `TRY_AGAIN_LATER` on next `sendTransaction` |
35+
| `stellar-insufficient-fee` | `fee`, `insufficient-fee` | Returns `ERROR` with `TxInsufficientFee` on next `sendTransaction` |
36+
| `stellar-get-transaction-not-found` | `not-found`, `get-tx-not-found` | Returns `NOT_FOUND` for next `getTransaction` |
37+
38+
All scenarios fire **exactly once** then revert to proxying real traffic.
39+
40+
## How to handle user arguments ($ARGUMENTS)
41+
42+
Based on the argument, execute the matching flow:
43+
44+
### `setup` — requires a network argument
45+
**Syntax:** `setup <network> [rpc-url]`
46+
47+
A bare `setup` with no network is **not allowed** — ask the user which network they want (stellar or evm).
48+
49+
1. Determine target RPC:
50+
- `setup stellar` → default: `https://soroban-testnet.stellar.org`
51+
- `setup evm` or `setup anvil` → default: `http://host.docker.internal:8545`
52+
- `setup stellar <url>` or `setup evm <url>` → use the provided URL as `WIREMOCK_PROXY_TARGET`
53+
2. Start WireMock:
54+
```bash
55+
WIREMOCK_PROXY_TARGET=<target> docker compose -f testing/wiremock/docker-compose.yaml up -d
56+
```
57+
For stellar with default target, just: `docker compose -f testing/wiremock/docker-compose.yaml up -d`
58+
3. Wait for health: poll `curl -sf http://localhost:9090/__admin/health` (retry up to 10 times with 2s sleep)
59+
4. Verify scenarios loaded: `curl -s http://localhost:9090/__admin/scenarios`
60+
5. Remind the user:
61+
- WireMock proxy is running on `http://localhost:9090` proxying to `<target>`
62+
- To use it, update the relevant network config `rpc_urls` to `["http://localhost:9090"]`
63+
- If the relayer runs in Docker, use `http://host.docker.internal:9090` instead
64+
- Show only the **relevant** scenarios: generic + the chosen network's specific scenarios
65+
- For Stellar: mention updating `config/networks/stellar.json` testnet entry
66+
- For EVM: mention updating the relevant EVM network config
67+
68+
### `teardown` or `stop`
69+
1. Run: `docker compose -f testing/wiremock/docker-compose.yaml down`
70+
2. Confirm stopped
71+
72+
### `status`
73+
1. Check if WireMock is running: `docker compose -f testing/wiremock/docker-compose.yaml ps`
74+
2. If running, show scenario states: `curl -s http://localhost:9090/__admin/scenarios`
75+
3. Show recent requests: `curl -s 'http://localhost:9090/__admin/requests?limit=10'`
76+
4. Parse and display in a readable format
77+
78+
### `check-metrics` or `metrics`
79+
1. Fetch relayer metrics: `curl -s http://localhost:8081/debug/metrics/scrape`
80+
2. Filter and display lines matching: `insufficient_fee|try_again_later|transactions_success|transactions_failed|stellar_submission_failures|stellar_try_again_later|rpc_call_latency|api_rpc_failures`
81+
3. Summarize what the metrics show
82+
83+
### A scenario name or shorthand
84+
1. First ensure WireMock is running (check health endpoint, start if needed)
85+
2. Resolve the scenario name — the user may use shorthand:
86+
- Generic: `timeout``rpc-timeout`, `500``rpc-500`, `reset``rpc-connection-reset`
87+
- Stellar: `try-again-later` or `tal``stellar-try-again-later`, `insufficient-fee` or `fee``stellar-insufficient-fee`, `not-found` or `get-tx-not-found``stellar-get-transaction-not-found`
88+
3. Arm the scenario:
89+
```bash
90+
curl -s -X PUT "http://localhost:9090/__admin/scenarios/{scenario-name}/state" \
91+
-H 'Content-Type: application/json' -d '{"state": "armed"}'
92+
```
93+
4. Verify it's armed by checking the scenario state
94+
5. Tell the user what will happen on the next matching RPC call
95+
96+
### `chaos` or `all-chaos`
97+
1. Ensure WireMock is running
98+
2. Arm ALL scenarios simultaneously
99+
3. Explain what will happen: scenarios are priority-based, so the most specific one fires first
100+
101+
### `reset`
102+
1. Reset all scenarios: `curl -s -X POST http://localhost:9090/__admin/scenarios/reset`
103+
2. Clear request log: `curl -s -X DELETE http://localhost:9090/__admin/requests`
104+
3. Confirm all scenarios are back to initial (inactive) state
105+
106+
## Important reminders
107+
108+
Always remind the user about these when relevant:
109+
- The relayer's network `rpc_urls` must point to `http://localhost:9090` (or `http://host.docker.internal:9090` from Docker) for WireMock to intercept traffic
110+
- After testing, they should revert `rpc_urls` back to the real RPC endpoint
111+
- Scenarios are one-shot: they fire once and revert. Re-arm to test again.
112+
- The relayer's metrics endpoint is at `http://localhost:8081/debug/metrics/scrape` (configurable via METRICS_PORT)
113+
- Generic scenarios (timeout, 500, connection-reset) work with any network — no need to change them when switching between Stellar and EVM testing
114+
115+
## Adding new network support
116+
117+
When adding EVM or other network-specific scenarios:
118+
1. Create `testing/wiremock/mappings/<network>/` directory
119+
2. Add scenario JSON files following the existing pattern (use `"requiredScenarioState": "armed"`)
120+
3. Mount the directory in `docker-compose.yaml` (uncomment or add the volume line)
121+
4. Update this skill's scenario tables above
122+
5. Restart WireMock to load new mappings

src/domain/transaction/stellar/submit.rs

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ use super::{
1313
use crate::{
1414
constants::STELLAR_INSUFFICIENT_FEE_MAX_RETRIES,
1515
jobs::JobProducerTrait,
16-
metrics::STELLAR_SUBMISSION_FAILURES,
16+
metrics::{STELLAR_SUBMISSION_FAILURES, TRANSACTIONS_INSUFFICIENT_FEE},
1717
models::{
18-
NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionMetadata,
19-
TransactionRepoModel, TransactionStatus, TransactionUpdateRequest,
18+
NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel,
19+
TransactionStatus, TransactionUpdateRequest,
2020
},
2121
repositories::{Repository, TransactionCounterTrait, TransactionRepository},
2222
services::{
@@ -166,17 +166,26 @@ where
166166
// transaction alive. The status checker will handle retries:
167167
// - Submitted txs: resubmitted with exponential backoff
168168
// - Sent txs: re-enqueued via handle_sent_state
169-
crate::metrics::STELLAR_TRY_AGAIN_LATER
170-
.with_label_values(&[&tx.relayer_id, &tx.status.to_string()])
171-
.inc();
169+
let mut meta = tx.metadata.clone().unwrap_or_default();
170+
meta.try_again_later_retries = meta.try_again_later_retries.saturating_add(1);
171+
172+
// Only push on first encounter (dedup: won't fire on retry 2, 3, etc.)
173+
if meta.try_again_later_retries == 1 {
174+
crate::metrics::STELLAR_TRY_AGAIN_LATER
175+
.with_label_values(&[&tx.relayer_id, &tx.status.to_string()])
176+
.inc();
177+
}
178+
172179
debug!(
173180
tx_id = %tx.id,
174181
relayer_id = %tx.relayer_id,
175182
status = ?tx.status,
183+
try_again_later_retries = meta.try_again_later_retries,
176184
"TRY_AGAIN_LATER — status checker will retry"
177185
);
178186
let update_req = TransactionUpdateRequest {
179187
sent_at: Some(Utc::now().to_rfc3339()),
188+
metadata: Some(meta),
180189
..Default::default()
181190
};
182191
let updated_tx = self
@@ -199,13 +208,17 @@ where
199208
.as_deref()
200209
.is_some_and(is_insufficient_fee_error)
201210
{
202-
let insufficient_fee_retries = tx
203-
.metadata
204-
.as_ref()
205-
.map_or(0, |metadata| metadata.insufficient_fee_retries)
206-
.saturating_add(1);
211+
let mut meta = tx.metadata.clone().unwrap_or_default();
212+
meta.insufficient_fee_retries = meta.insufficient_fee_retries.saturating_add(1);
213+
214+
// Only push on first encounter (dedup: won't fire on retry 2, 3, etc.)
215+
if meta.insufficient_fee_retries == 1 {
216+
TRANSACTIONS_INSUFFICIENT_FEE
217+
.with_label_values(&[tx.relayer_id.as_str(), "stellar"])
218+
.inc();
219+
}
207220

208-
if insufficient_fee_retries > STELLAR_INSUFFICIENT_FEE_MAX_RETRIES {
221+
if meta.insufficient_fee_retries > STELLAR_INSUFFICIENT_FEE_MAX_RETRIES {
209222
STELLAR_SUBMISSION_FAILURES
210223
.with_label_values(&["error", "tx_insufficient_fee"])
211224
.inc();
@@ -218,23 +231,13 @@ where
218231
tx_id = %tx.id,
219232
relayer_id = %tx.relayer_id,
220233
status = ?tx.status,
221-
insufficient_fee_retries,
234+
insufficient_fee_retries = meta.insufficient_fee_retries,
222235
result_code = decoded_result_code.as_deref().unwrap_or("Unknown"),
223236
"ERROR with insufficient fee — status checker will retry"
224237
);
225238
let update_req = TransactionUpdateRequest {
226239
sent_at: Some(Utc::now().to_rfc3339()),
227-
metadata: Some(TransactionMetadata {
228-
consecutive_failures: tx
229-
.metadata
230-
.as_ref()
231-
.map_or(0, |metadata| metadata.consecutive_failures),
232-
total_failures: tx
233-
.metadata
234-
.as_ref()
235-
.map_or(0, |metadata| metadata.total_failures),
236-
insufficient_fee_retries,
237-
}),
240+
metadata: Some(meta),
238241
..Default::default()
239242
};
240243
let updated_tx = self
@@ -414,6 +417,7 @@ mod tests {
414417
use soroban_rs::xdr::WriteXdr;
415418

416419
use crate::domain::transaction::stellar::test_helpers::*;
420+
use crate::models::TransactionMetadata;
417421

418422
/// Helper to create a SendTransactionResponse with given status
419423
fn create_send_tx_response(status: &str, hash: &str) -> SendTransactionResponse {
@@ -970,11 +974,18 @@ mod tests {
970974
Box::pin(async move { Ok(r) })
971975
});
972976

973-
// partial_update is called to refresh sent_at — status should NOT change
977+
// partial_update is called to refresh sent_at and persist metadata — status should NOT change
974978
mocks
975979
.tx_repo
976980
.expect_partial_update()
977-
.withf(|_, upd| upd.sent_at.is_some() && upd.status.is_none())
981+
.withf(|_, upd| {
982+
upd.sent_at.is_some()
983+
&& upd.status.is_none()
984+
&& upd
985+
.metadata
986+
.as_ref()
987+
.is_some_and(|m| m.try_again_later_retries == 1)
988+
})
978989
.returning(|id, _upd| {
979990
let mut tx = create_test_transaction("relayer-1");
980991
tx.id = id;

0 commit comments

Comments
 (0)