Skip to content

Commit dad6cd0

Browse files
authored
Merge pull request #839 from mogbonjubolaolasunkanmi-art/feature/cli-error-handling-setup
Add CLI timeout/JSON error handling, CHANGELOG, and Vitest coverage setup
2 parents 3cab721 + 4b3618e commit dad6cd0

4 files changed

Lines changed: 122 additions & 5 deletions

File tree

packages/cli/CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [0.1.0] — 2026-07-29
9+
10+
### Added
11+
12+
- Initial release of `@stellar-explain/cli`
13+
- `tx <hash>` command — explain a Stellar transaction by hash
14+
- `account <address>` command — explain a Stellar account by address
15+
- `health` command — check backend API health
16+
- `batch <file>` command — process a batch of lookups from a JSON file
17+
- `cache clear` command — clear local response cache
18+
- `version` command — show CLI and API versions
19+
- `--url` option — configure a custom backend URL
20+
- `--no-update-check` option — disable background update check
21+
- Local disk cache with in-memory fallback (`~/.stellar-explain/`)
22+
- Background update check on startup
23+
- Colored error output formatting

packages/cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"build:watch": "tsc --watch",
1616
"dev": "tsc --watch",
1717
"test": "vitest run",
18+
"test:watch": "vitest",
19+
"test:coverage": "vitest run --coverage",
1820
"lint": "eslint src",
1921
"typecheck": "tsc --noEmit",
2022
"generate:man": "bash scripts/generate-man.sh",
@@ -29,6 +31,7 @@
2931
"devDependencies": {
3032
"typescript": "^5.5.0",
3133
"vitest": "^2.0.0",
34+
"@vitest/coverage-v8": "^2.0.0",
3235
"@types/node": "^20.0.0",
3336
"@changesets/cli": "^2.27.0"
3437
}

packages/cli/src/client/api.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,33 @@ export interface ApiResponse<T> {
55
data: T;
66
}
77

8+
const DEFAULT_TIMEOUT_MS = 30_000;
9+
810
export class ApiClient {
911
private baseUrl: string;
12+
private timeoutMs: number;
1013

11-
constructor(baseUrl: string) {
14+
constructor(baseUrl: string, timeoutMs: number = DEFAULT_TIMEOUT_MS) {
1215
this.baseUrl = baseUrl.replace(/\/$/, '');
16+
this.timeoutMs = timeoutMs;
1317
warnInsecureUrl(this.baseUrl);
1418
}
1519

1620
async get<T>(endpoint: string): Promise<T> {
1721
const url = `${this.baseUrl}${endpoint}`;
22+
const controller = new AbortController();
23+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
24+
1825
let response: Response;
1926
try {
20-
response = await fetch(url);
27+
response = await fetch(url, { signal: controller.signal });
2128
} catch (err: unknown) {
29+
clearTimeout(timer);
30+
if (err instanceof DOMException && err.name === 'AbortError') {
31+
throw new Error(
32+
`Request timed out after ${this.timeoutMs / 1000}s. Check your connection or try --timeout to increase the limit.`
33+
);
34+
}
2235
if (err instanceof TypeError) {
2336
const msg = String(err.message);
2437
if (msg.includes('ECONNREFUSED')) {
@@ -30,13 +43,25 @@ export class ApiClient {
3043
}
3144
throw err;
3245
}
46+
clearTimeout(timer);
3347

3448
if (!response.ok) {
35-
throw new Error(`API error: ${response.status} ${response.statusText}`);
49+
const bodyText = await response.text();
50+
throw new Error(
51+
`API error: ${response.status} ${response.statusText}${bodyText.slice(0, 200)}`
52+
);
3653
}
3754

38-
const body = (await response.json()) as ApiResponse<T>;
39-
return body.data;
55+
const raw = await response.text();
56+
let parsed: ApiResponse<T>;
57+
try {
58+
parsed = JSON.parse(raw) as ApiResponse<T>;
59+
} catch {
60+
throw new Error(
61+
`Unexpected response from API at ${url}. Expected JSON but received:\n${raw.slice(0, 300)}`
62+
);
63+
}
64+
return parsed.data;
4065
}
4166

4267
async health(): Promise<{ status: string; horizon_reachable: boolean; version: string }> {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { ApiClient } from '../../src/client/api.js';
3+
4+
const BASE_URL = 'https://stellar-explain-core.onrender.com';
5+
6+
describe('ApiClient', () => {
7+
beforeEach(() => {
8+
vi.restoreAllMocks();
9+
});
10+
11+
it('throws a friendly message when API returns non-JSON response', async () => {
12+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
13+
new Response('<html>502 Bad Gateway</html>', {
14+
status: 502,
15+
statusText: 'Bad Gateway',
16+
headers: { 'content-type': 'text/html' },
17+
})
18+
);
19+
20+
const client = new ApiClient(BASE_URL, 10_000);
21+
await expect(client.health()).rejects.toThrow('Unexpected response from API');
22+
});
23+
24+
it('throws a friendly message on network timeout', async () => {
25+
vi.spyOn(globalThis, 'fetch').mockImplementation(
26+
() =>
27+
new Promise((_, reject) => {
28+
const err = new DOMException('The operation was aborted', 'AbortError');
29+
reject(err);
30+
})
31+
);
32+
33+
const client = new ApiClient(BASE_URL, 100);
34+
await expect(client.health()).rejects.toThrow('Request timed out after');
35+
});
36+
37+
it('throws a friendly message on connection refused', async () => {
38+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(
39+
new TypeError('fetch failed: reason: ECONNREFUSED')
40+
);
41+
42+
const client = new ApiClient('http://localhost:1');
43+
await expect(client.health()).rejects.toThrow('Connection refused');
44+
});
45+
46+
it('throws a friendly message on DNS failure', async () => {
47+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(
48+
new TypeError('fetch failed: ENOTFOUND nonexistent.example.com')
49+
);
50+
51+
const client = new ApiClient('http://nonexistent.example.com');
52+
await expect(client.health()).rejects.toThrow('Cannot reach');
53+
});
54+
55+
it('includes response body text in API error messages', async () => {
56+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
57+
new Response('Service Unavailable', {
58+
status: 503,
59+
statusText: 'Service Unavailable',
60+
})
61+
);
62+
63+
const client = new ApiClient(BASE_URL, 10_000);
64+
await expect(client.health()).rejects.toThrow('API error: 503');
65+
});
66+
});

0 commit comments

Comments
 (0)