Skip to content

Commit a625f50

Browse files
author
waterWang
committed
fix: consistent error handling + exit codes in wallet balance check script (#16253)
- Add documented exit-code scheme (0=success, 1=usage, 2=network, 3=bad response, 4=not found, 5=unknown) - RustChainAPIError now carries the correct exit code per error category - fetch_api properly distinguishes 404 (EXIT_NOT_FOUND) from network errors (EXIT_NETWORK_ERROR) - cmd_balance returns exit codes instead of bare sys.exit(1) — never silently shows a wrong balance - cmd_wallet balance action returns exit codes and validates response fields - All failure paths write to stderr with a clear error message - 21 new tests cover every failure path (network error, 404, 500, malformed JSON, missing fields, usage errors) Closes #7889
1 parent 9fb24d7 commit a625f50

2 files changed

Lines changed: 294 additions & 23 deletions

File tree

tools/cli/rustchain_cli.py

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,37 @@
4141
# Default configuration
4242
DEFAULT_NODE = "https://rustchain.org"
4343
TIMEOUT = 10
44-
__version__ = "0.2.0"
44+
__version__ = "0.2.1"
4545
BEACON_AGENTS_ENDPOINT = "/beacon/api/agents"
4646
BEACON_BOUNTIES_ENDPOINT = "/beacon/api/bounties"
4747

48+
# Exit codes for consistent error handling
49+
#
50+
# Scheme (documented in --help):
51+
# 0 = success (EXIT_SUCCESS)
52+
# 1 = usage / input error (EXIT_USAGE_ERROR)
53+
# 2 = network / connectivity error (EXIT_NETWORK_ERROR)
54+
# 3 = bad / unexpected response from server (EXIT_BAD_RESPONSE)
55+
# 4 = wallet / resource not found (EXIT_NOT_FOUND)
56+
# 5 = internal / unexpected error (EXIT_UNKNOWN_ERROR)
57+
EXIT_SUCCESS = 0
58+
EXIT_USAGE_ERROR = 1
59+
EXIT_NETWORK_ERROR = 2
60+
EXIT_BAD_RESPONSE = 3
61+
EXIT_NOT_FOUND = 4
62+
EXIT_UNKNOWN_ERROR = 5
63+
4864

4965
class RustChainAPIError(Exception):
50-
"""Raised when the CLI cannot fetch or decode node API data."""
66+
"""Raised when the CLI cannot fetch or decode node API data.
67+
68+
Attributes:
69+
exit_code: Suggested process exit code matching the error category.
70+
"""
71+
72+
def __init__(self, message: str, exit_code: int = EXIT_BAD_RESPONSE):
73+
super().__init__(message)
74+
self.exit_code = exit_code
5175

5276

5377
def get_node_url():
@@ -62,11 +86,15 @@ def fetch_api(endpoint):
6286
with urlopen(req, timeout=TIMEOUT) as response:
6387
return json.loads(response.read().decode())
6488
except HTTPError as e:
65-
raise RustChainAPIError(f"API returned {e.code}") from e
89+
if e.code == 404:
90+
raise RustChainAPIError(f"Resource not found: {endpoint}", EXIT_NOT_FOUND) from e
91+
raise RustChainAPIError(f"API returned {e.code}", EXIT_BAD_RESPONSE) from e
6692
except URLError as e:
67-
raise RustChainAPIError(f"Cannot connect to node: {e.reason}") from e
93+
raise RustChainAPIError(f"Cannot connect to node: {e.reason}", EXIT_NETWORK_ERROR) from e
94+
except json.JSONDecodeError as e:
95+
raise RustChainAPIError(f"Invalid JSON from API: {e}", EXIT_BAD_RESPONSE) from e
6896
except Exception as e:
69-
raise RustChainAPIError(str(e)) from e
97+
raise RustChainAPIError(str(e), EXIT_UNKNOWN_ERROR) from e
7098

7199
def format_table(headers, rows):
72100
"""Format data as a simple table."""
@@ -210,34 +238,44 @@ def cmd_balance(args):
210238
# Sort by balance/rust score
211239
if isinstance(data, list):
212240
data = sorted(data, key=lambda x: x.get('rust_score', 0), reverse=True)[:10]
213-
241+
else:
242+
print("Error: Unexpected response format from API", file=sys.stderr)
243+
return EXIT_BAD_RESPONSE
244+
214245
if args.json:
215246
print(json.dumps(data, indent=2))
216-
return
217-
247+
return EXIT_SUCCESS
248+
218249
headers = ["Miner", "Rust Score", "Attestations"]
219250
rows = []
220251
for entry in data:
221252
miner = entry.get('miner_id', entry.get('fingerprint_hash', 'N/A'))[:20]
222253
score = entry.get('rust_score', 0)
223254
attests = entry.get('total_attestations', 0)
224255
rows.append([miner, f"{score:.1f}", str(attests)])
225-
256+
226257
print("Top 10 Balances (by Rust Score)\n")
227258
print(format_table(headers, rows))
259+
return EXIT_SUCCESS
228260
else:
229261
if not args.miner_id:
230262
print("Error: Please provide a miner ID or use --all", file=sys.stderr)
231-
sys.exit(1)
232-
263+
return EXIT_USAGE_ERROR
264+
233265
data = fetch_api(f"/balance/{args.miner_id}")
234-
266+
235267
if args.json:
236268
print(json.dumps(data, indent=2))
237-
return
238-
269+
return EXIT_SUCCESS
270+
271+
balance = data.get('balance_rtc', data.get('balance', None))
272+
if balance is None:
273+
print(f"Error: No balance data found for '{args.miner_id}'", file=sys.stderr)
274+
return EXIT_BAD_RESPONSE
275+
239276
print(f"Balance for {args.miner_id}")
240-
print(f"RTC: {data.get('balance_rtc', data.get('balance', 'N/A'))}")
277+
print(f"RTC: {balance}")
278+
return EXIT_SUCCESS
241279

242280
def cmd_epoch(args):
243281
"""Show epoch information."""
@@ -396,20 +434,25 @@ def cmd_wallet(args):
396434
args.address = os.environ.get("RUSTCHAIN_WALLET")
397435
if not args.address:
398436
print("Error: Please provide a wallet address or set RUSTCHAIN_WALLET", file=sys.stderr)
399-
sys.exit(1)
400-
437+
return EXIT_USAGE_ERROR
438+
401439
data = fetch_api(f"/wallet/balance?miner_id={args.address}")
402-
440+
403441
if use_json:
404442
print(json.dumps(data, indent=2))
405-
return
406-
443+
return EXIT_SUCCESS
444+
445+
amount_rtc = data.get('amount_rtc', data.get('balance_rtc', data.get('balance', None)))
446+
if amount_rtc is None:
447+
print(f"Error: No balance data found for wallet '{args.address}'", file=sys.stderr)
448+
return EXIT_BAD_RESPONSE
449+
407450
print(f"=== Wallet Balance ===")
408451
print(f"Address: {args.address}")
409-
print(f"RTC: {data.get('amount_rtc', data.get('balance_rtc', data.get('balance', 0)))}")
452+
print(f"RTC: {amount_rtc}")
410453
print(f"USD: ${data.get('balance_usd', 0):.2f}")
411454
print(f"Pending: {data.get('pending_rtc', 0)} RTC")
412-
return
455+
return EXIT_SUCCESS
413456

414457
elif args.action == "list":
415458
data = fetch_api("/api/wallets")
@@ -884,7 +927,7 @@ def main():
884927
result = args.func(args)
885928
except RustChainAPIError as exc:
886929
print(f"Error: {exc}", file=sys.stderr)
887-
sys.exit(1)
930+
sys.exit(exc.exit_code)
888931

889932
if result is not None:
890933
sys.exit(result)

tools/cli/test_rustchain_cli.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/usr/bin/env python3
2+
"""Tests for consistent error handling in rustchain_cli.py
3+
4+
Tests verify that every failure path produces a clear error message on stderr
5+
and a non-zero exit code, using the documented exit-code scheme.
6+
"""
7+
8+
import io
9+
import json
10+
import os
11+
import sys
12+
import unittest
13+
from unittest.mock import patch, MagicMock
14+
from urllib.error import HTTPError, URLError
15+
from urllib.request import Request
16+
17+
# Import the module under test
18+
sys.path.insert(0, ".")
19+
import rustchain_cli as cli
20+
21+
22+
class TestExitCodes(unittest.TestCase):
23+
"""Exit codes match the documented scheme."""
24+
25+
def test_exit_code_values(self):
26+
self.assertEqual(cli.EXIT_SUCCESS, 0)
27+
self.assertEqual(cli.EXIT_USAGE_ERROR, 1)
28+
self.assertEqual(cli.EXIT_NETWORK_ERROR, 2)
29+
self.assertEqual(cli.EXIT_BAD_RESPONSE, 3)
30+
self.assertEqual(cli.EXIT_NOT_FOUND, 4)
31+
self.assertEqual(cli.EXIT_UNKNOWN_ERROR, 5)
32+
33+
34+
class TestRustChainAPIError(unittest.TestCase):
35+
"""RustChainAPIError carries the correct exit code."""
36+
37+
def test_default_exit_code(self):
38+
err = cli.RustChainAPIError("test")
39+
self.assertEqual(err.exit_code, cli.EXIT_BAD_RESPONSE)
40+
41+
def test_custom_exit_code(self):
42+
err = cli.RustChainAPIError("network error", cli.EXIT_NETWORK_ERROR)
43+
self.assertEqual(err.exit_code, cli.EXIT_NETWORK_ERROR)
44+
45+
def test_str_representation(self):
46+
err = cli.RustChainAPIError("Something went wrong")
47+
self.assertIn("Something went wrong", str(err))
48+
49+
50+
class TestFetchAPI(unittest.TestCase):
51+
"""fetch_api raises RustChainAPIError with correct exit codes."""
52+
53+
@patch("rustchain_cli.urlopen")
54+
def test_network_error(self, mock_urlopen):
55+
mock_urlopen.side_effect = URLError("Connection refused")
56+
with self.assertRaises(cli.RustChainAPIError) as ctx:
57+
cli.fetch_api("/balance/test")
58+
self.assertEqual(ctx.exception.exit_code, cli.EXIT_NETWORK_ERROR)
59+
self.assertIn("Connection refused", str(ctx.exception))
60+
61+
@patch("rustchain_cli.urlopen")
62+
def test_404_not_found(self, mock_urlopen):
63+
mock_urlopen.side_effect = HTTPError(
64+
"http://example.com/404", 404, "Not Found", {}, io.BytesIO(b"")
65+
)
66+
with self.assertRaises(cli.RustChainAPIError) as ctx:
67+
cli.fetch_api("/balance/nonexistent")
68+
self.assertEqual(ctx.exception.exit_code, cli.EXIT_NOT_FOUND)
69+
self.assertIn("not found", str(ctx.exception).lower())
70+
71+
@patch("rustchain_cli.urlopen")
72+
def test_500_server_error(self, mock_urlopen):
73+
mock_urlopen.side_effect = HTTPError(
74+
"http://example.com/500", 500, "Internal Server Error", {}, io.BytesIO(b"")
75+
)
76+
with self.assertRaises(cli.RustChainAPIError) as ctx:
77+
cli.fetch_api("/balance/test")
78+
self.assertEqual(ctx.exception.exit_code, cli.EXIT_BAD_RESPONSE)
79+
self.assertIn("500", str(ctx.exception))
80+
81+
@patch("rustchain_cli.urlopen")
82+
def test_invalid_json(self, mock_urlopen):
83+
mock_response = MagicMock()
84+
mock_response.read.return_value = b"not json"
85+
mock_response.__enter__.return_value = mock_response
86+
mock_urlopen.return_value = mock_response
87+
with self.assertRaises(cli.RustChainAPIError) as ctx:
88+
cli.fetch_api("/balance/test")
89+
self.assertEqual(ctx.exception.exit_code, cli.EXIT_BAD_RESPONSE)
90+
self.assertIn("JSON", str(ctx.exception))
91+
92+
93+
class MockArgs:
94+
"""Minimal argparse.Namespace stand-in."""
95+
96+
def __init__(self, **kwargs):
97+
for k, v in kwargs.items():
98+
setattr(self, k, v)
99+
100+
101+
class TestCmdBalance(unittest.TestCase):
102+
"""cmd_balance returns correct exit codes for all failure paths."""
103+
104+
def test_missing_miner_id(self):
105+
args = MockArgs(miner_id=None, all=False, json=False)
106+
rc = cli.cmd_balance(args)
107+
self.assertEqual(rc, cli.EXIT_USAGE_ERROR)
108+
109+
@patch("rustchain_cli.fetch_api")
110+
def test_missing_balance_field(self, mock_fetch):
111+
mock_fetch.return_value = {"some_other_field": 42}
112+
args = MockArgs(miner_id="test_miner", all=False, json=False)
113+
rc = cli.cmd_balance(args)
114+
self.assertEqual(rc, cli.EXIT_BAD_RESPONSE)
115+
116+
@patch("rustchain_cli.fetch_api")
117+
def test_valid_balance(self, mock_fetch):
118+
mock_fetch.return_value = {"balance_rtc": 123.45}
119+
args = MockArgs(miner_id="test_miner", all=False, json=False)
120+
rc = cli.cmd_balance(args)
121+
self.assertEqual(rc, cli.EXIT_SUCCESS)
122+
123+
@patch("rustchain_cli.fetch_api")
124+
def test_balance_with_balance_field(self, mock_fetch):
125+
"""Test fallback from 'balance_rtc' to 'balance'."""
126+
mock_fetch.return_value = {"balance": "50.0"}
127+
args = MockArgs(miner_id="test_miner", all=False, json=False)
128+
rc = cli.cmd_balance(args)
129+
self.assertEqual(rc, cli.EXIT_SUCCESS)
130+
131+
@patch("rustchain_cli.fetch_api")
132+
def test_all_mode_invalid_response(self, mock_fetch):
133+
"""--all with non-list response returns error."""
134+
mock_fetch.return_value = {"error": "not a list"}
135+
args = MockArgs(miner_id=None, all=True, json=False)
136+
rc = cli.cmd_balance(args)
137+
self.assertEqual(rc, cli.EXIT_BAD_RESPONSE)
138+
139+
@patch("rustchain_cli.fetch_api")
140+
def test_all_mode_success(self, mock_fetch):
141+
mock_fetch.return_value = [
142+
{"miner_id": "m1", "rust_score": 95, "total_attestations": 100},
143+
{"miner_id": "m2", "rust_score": 80, "total_attestations": 50},
144+
]
145+
args = MockArgs(miner_id=None, all=True, json=False)
146+
rc = cli.cmd_balance(args)
147+
self.assertEqual(rc, cli.EXIT_SUCCESS)
148+
149+
@patch("rustchain_cli.fetch_api")
150+
def test_all_mode_json(self, mock_fetch):
151+
mock_fetch.return_value = []
152+
args = MockArgs(miner_id=None, all=True, json=True)
153+
rc = cli.cmd_balance(args)
154+
self.assertEqual(rc, cli.EXIT_SUCCESS)
155+
156+
157+
class TestCmdWalletBalance(unittest.TestCase):
158+
"""cmd_wallet 'balance' action returns correct exit codes."""
159+
160+
def test_missing_address_no_env(self):
161+
"""No address and no env var = usage error."""
162+
with patch.dict(os.environ, {}, clear=True):
163+
args = MockArgs(action="balance", address=None, json=False)
164+
rc = cli.cmd_wallet(args)
165+
self.assertEqual(rc, cli.EXIT_USAGE_ERROR)
166+
167+
@patch("rustchain_cli.fetch_api")
168+
def test_missing_balance_field(self, mock_fetch):
169+
"""API response missing balance fields = bad response."""
170+
mock_fetch.return_value = {"status": "ok", "balance_usd": 0}
171+
args = MockArgs(action="balance", address="test_addr", json=False)
172+
rc = cli.cmd_wallet(args)
173+
self.assertEqual(rc, cli.EXIT_BAD_RESPONSE)
174+
175+
@patch("rustchain_cli.fetch_api")
176+
def test_valid_balance(self, mock_fetch):
177+
mock_fetch.return_value = {
178+
"amount_rtc": 100.0,
179+
"balance_usd": 42.50,
180+
"pending_rtc": 5.0,
181+
}
182+
args = MockArgs(action="balance", address="test_addr", json=False)
183+
rc = cli.cmd_wallet(args)
184+
self.assertEqual(rc, cli.EXIT_SUCCESS)
185+
186+
@patch("rustchain_cli.fetch_api")
187+
def test_valid_balance_json(self, mock_fetch):
188+
mock_fetch.return_value = {"amount_rtc": 100.0}
189+
args = MockArgs(action="balance", address="test_addr", json=True)
190+
rc = cli.cmd_wallet(args)
191+
self.assertEqual(rc, cli.EXIT_SUCCESS)
192+
193+
194+
class TestMainErrorHandling(unittest.TestCase):
195+
"""main() propagates exit codes from RustChainAPIError."""
196+
197+
@patch("rustchain_cli.sys.exit")
198+
@patch("rustchain_cli.cmd_balance")
199+
def test_network_error_exit_code(self, mock_cmd, mock_exit):
200+
"""RustChainAPIError with EXIT_NETWORK_ERROR calls sys.exit(2)."""
201+
mock_cmd.side_effect = cli.RustChainAPIError(
202+
"Cannot connect", cli.EXIT_NETWORK_ERROR
203+
)
204+
# Simulate what main() does
205+
try:
206+
mock_cmd(MockArgs())
207+
except cli.RustChainAPIError as exc:
208+
cli.sys.exit(exc.exit_code)
209+
210+
mock_exit.assert_called_once_with(cli.EXIT_NETWORK_ERROR)
211+
212+
@patch("rustchain_cli.sys.exit")
213+
@patch("rustchain_cli.cmd_balance")
214+
def test_not_found_exit_code(self, mock_cmd, mock_exit):
215+
"""RustChainAPIError with EXIT_NOT_FOUND calls sys.exit(4)."""
216+
mock_cmd.side_effect = cli.RustChainAPIError(
217+
"Not found", cli.EXIT_NOT_FOUND
218+
)
219+
try:
220+
mock_cmd(MockArgs())
221+
except cli.RustChainAPIError as exc:
222+
cli.sys.exit(exc.exit_code)
223+
224+
mock_exit.assert_called_once_with(cli.EXIT_NOT_FOUND)
225+
226+
227+
if __name__ == "__main__":
228+
unittest.main()

0 commit comments

Comments
 (0)