Skip to content

Commit e3277ee

Browse files
satoshai-devclaude
andauthored
feat: custom error types for better error handling (#19)
* feat: add custom error types for better error handling Replace generic Error throws with typed error classes (NetworkError, FeeEstimationError, BroadcastError, ConfirmationError, ConfigurationError, UserRejectionError) so users can catch specific failure modes programmatically. Closes #13 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add changeset for custom error types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: forward ErrorOptions for cause chaining in all error subclasses All custom error subclasses now accept an optional ErrorOptions parameter and forward it to the base class, enabling error cause chaining via `new NetworkError('msg', 500, url, body, { cause })`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c7768a9 commit e3277ee

10 files changed

Lines changed: 223 additions & 8 deletions

File tree

.changeset/custom-error-types.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@satoshai/playstacks': minor
3+
---
4+
5+
Add custom error types for programmatic error handling
6+
7+
Introduces a typed error hierarchy (`PlaystacksError`, `NetworkError`, `BroadcastError`, `ConfirmationError`, `UserRejectionError`, `ConfigurationError`, `FeeEstimationError`) so consumers can catch specific errors with `instanceof` checks instead of parsing error message strings.

packages/playstacks/src/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ConfigurationError } from './errors.js';
2+
13
export type NetworkName = 'mainnet' | 'testnet' | 'devnet';
24

35
export interface CustomNetwork {
@@ -71,7 +73,7 @@ export function resolveConfig(config: PlaystacksConfig, derivedPrivateKey?: stri
7173
: derivedPrivateKey;
7274

7375
if (!privateKey) {
74-
throw new Error('No private key available. Provide privateKey or mnemonic.');
76+
throw new ConfigurationError('No private key available. Provide privateKey or mnemonic.');
7577
}
7678

7779
return {

packages/playstacks/src/errors.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/** Base error class for all Playstacks errors. */
2+
export class PlaystacksError extends Error {
3+
constructor(message: string, options?: ErrorOptions) {
4+
super(message, options);
5+
this.name = 'PlaystacksError';
6+
}
7+
}
8+
9+
/** Thrown when the Stacks API is unreachable or returns an HTTP error. */
10+
export class NetworkError extends PlaystacksError {
11+
readonly statusCode: number;
12+
readonly url: string;
13+
readonly responseBody?: string;
14+
15+
constructor(
16+
message: string,
17+
statusCode: number,
18+
url: string,
19+
responseBody?: string,
20+
options?: ErrorOptions,
21+
) {
22+
super(message, options);
23+
this.name = 'NetworkError';
24+
this.statusCode = statusCode;
25+
this.url = url;
26+
this.responseBody = responseBody;
27+
}
28+
}
29+
30+
/** Thrown when fee estimation fails. */
31+
export class FeeEstimationError extends PlaystacksError {
32+
readonly statusCode?: number;
33+
readonly responseBody?: string;
34+
35+
constructor(
36+
message: string,
37+
statusCode?: number,
38+
responseBody?: string,
39+
options?: ErrorOptions,
40+
) {
41+
super(message, options);
42+
this.name = 'FeeEstimationError';
43+
this.statusCode = statusCode;
44+
this.responseBody = responseBody;
45+
}
46+
}
47+
48+
/** Thrown when a signed transaction is rejected during broadcast. */
49+
export class BroadcastError extends PlaystacksError {
50+
readonly reason?: string;
51+
52+
constructor(message: string, reason?: string, options?: ErrorOptions) {
53+
super(message, options);
54+
this.name = 'BroadcastError';
55+
this.reason = reason;
56+
}
57+
}
58+
59+
/** Thrown when a transaction does not confirm within the configured timeout. */
60+
export class ConfirmationError extends PlaystacksError {
61+
readonly txid: string;
62+
readonly timeoutMs: number;
63+
64+
constructor(message: string, txid: string, timeoutMs: number, options?: ErrorOptions) {
65+
super(message, options);
66+
this.name = 'ConfirmationError';
67+
this.txid = txid;
68+
this.timeoutMs = timeoutMs;
69+
}
70+
}
71+
72+
/** Thrown when the mock wallet rejects a request (code 4001). */
73+
export class UserRejectionError extends PlaystacksError {
74+
readonly code: number;
75+
76+
constructor(message = 'User rejected the request', code = 4001, options?: ErrorOptions) {
77+
super(message, options);
78+
this.name = 'UserRejectionError';
79+
this.code = code;
80+
}
81+
}
82+
83+
/** Thrown when configuration is invalid (missing key, unknown network, bad key length). */
84+
export class ConfigurationError extends PlaystacksError {
85+
constructor(message: string, options?: ErrorOptions) {
86+
super(message, options);
87+
this.name = 'ConfigurationError';
88+
}
89+
}

packages/playstacks/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
// Public API
22
export { testWithStacks, type StacksFixture, type StacksFixtures } from './fixtures.js';
3+
export {
4+
PlaystacksError,
5+
NetworkError,
6+
FeeEstimationError,
7+
BroadcastError,
8+
ConfirmationError,
9+
UserRejectionError,
10+
ConfigurationError,
11+
} from './errors.js';
312
export {
413
type PlaystacksConfig,
514
type PrivateKeyConfig,

packages/playstacks/src/network/api-client.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ResolvedNetwork } from './network-config.js';
2+
import { NetworkError, FeeEstimationError } from '../errors.js';
23

34
export interface AccountInfo {
45
balance: string;
@@ -16,7 +17,12 @@ async function fetchJson<T>(url: string): Promise<T> {
1617
const response = await fetch(url);
1718
if (!response.ok) {
1819
const body = await response.text().catch(() => '');
19-
throw new Error(`Stacks API error ${response.status}: ${url}\n${body}`);
20+
throw new NetworkError(
21+
`Stacks API error ${response.status}: ${url}`,
22+
response.status,
23+
url,
24+
body || undefined,
25+
);
2026
}
2127
return response.json() as Promise<T>;
2228
}
@@ -85,7 +91,11 @@ export async function fetchTransactionFeeEstimate(
8591

8692
if (!response.ok) {
8793
const body = await response.text().catch(() => '');
88-
throw new Error(`Fee estimation failed ${response.status}: ${body}`);
94+
throw new FeeEstimationError(
95+
`Fee estimation failed ${response.status}`,
96+
response.status,
97+
body || undefined,
98+
);
8999
}
90100

91101
return response.json() as Promise<FeeEstimation>;

packages/playstacks/src/network/network-config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { NetworkOption } from '../config.js';
2+
import { ConfigurationError } from '../errors.js';
23

34
export interface ResolvedNetwork {
45
/** Human-readable name */
@@ -31,7 +32,7 @@ export function resolveNetwork(option: NetworkOption): ResolvedNetwork {
3132
if (typeof option === 'string') {
3233
const network = NETWORK_MAP[option];
3334
if (!network) {
34-
throw new Error(
35+
throw new ConfigurationError(
3536
`Unknown network "${option}". Use "mainnet", "testnet", "devnet", or { url: "..." }.`
3637
);
3738
}

packages/playstacks/src/tx/broadcaster.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type StacksTransactionWire,
44
} from '@stacks/transactions';
55
import type { ResolvedNetwork } from '../network/network-config.js';
6+
import { BroadcastError } from '../errors.js';
67

78
export interface BroadcastResult {
89
txid: string;
@@ -31,5 +32,5 @@ export async function broadcast(
3132
}
3233

3334
const errorStr = JSON.stringify(result);
34-
throw new Error(`Transaction broadcast failed: ${errorStr}`);
35+
throw new BroadcastError(`Transaction broadcast failed: ${errorStr}`, errorStr);
3536
}

packages/playstacks/src/tx/confirmation.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ResolvedConfig } from '../config.js';
22
import type { ResolvedNetwork } from '../network/network-config.js';
33
import { fetchTransactionStatus } from '../network/api-client.js';
4+
import { ConfirmationError } from '../errors.js';
45

56
/** Terminal transaction status on the Stacks network */
67
export type TxStatus = 'success' | 'abort_by_response' | 'abort_by_post_condition';
@@ -41,8 +42,10 @@ export async function waitForConfirmation(
4142
await sleep(config.pollInterval);
4243
}
4344

44-
throw new Error(
45-
`Transaction ${normalizedTxid} did not confirm within ${config.timeout}ms`
45+
throw new ConfirmationError(
46+
`Transaction ${normalizedTxid} did not confirm within ${config.timeout}ms`,
47+
normalizedTxid,
48+
config.timeout,
4649
);
4750
}
4851

packages/playstacks/src/wallet/key-manager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@stacks/transactions';
77
import { generateWallet, generateNewAccount } from '@stacks/wallet-sdk';
88
import type { WalletIdentity } from './types.js';
9+
import { ConfigurationError } from '../errors.js';
910

1011
/**
1112
* Normalize a private key hex string.
@@ -16,7 +17,7 @@ function normalizePrivateKey(key: string): string {
1617
const hex = key.startsWith('0x') ? key.slice(2) : key;
1718
// Validate length: 64 chars (32 bytes) or 66 chars (32 bytes + 01 suffix)
1819
if (hex.length !== 64 && hex.length !== 66) {
19-
throw new Error(
20+
throw new ConfigurationError(
2021
`Invalid private key length: expected 64 or 66 hex chars, got ${hex.length}`
2122
);
2223
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
PlaystacksError,
4+
NetworkError,
5+
FeeEstimationError,
6+
BroadcastError,
7+
ConfirmationError,
8+
UserRejectionError,
9+
ConfigurationError,
10+
} from '../../src/errors.js';
11+
12+
describe('custom error types', () => {
13+
it('PlaystacksError is instanceof Error', () => {
14+
const err = new PlaystacksError('base error');
15+
expect(err).toBeInstanceOf(Error);
16+
expect(err).toBeInstanceOf(PlaystacksError);
17+
expect(err.name).toBe('PlaystacksError');
18+
expect(err.message).toBe('base error');
19+
});
20+
21+
it('NetworkError includes status, url, and body', () => {
22+
const err = new NetworkError('API error 500', 500, 'https://api.hiro.so/v2/info', 'Internal Server Error');
23+
expect(err).toBeInstanceOf(PlaystacksError);
24+
expect(err).toBeInstanceOf(NetworkError);
25+
expect(err.name).toBe('NetworkError');
26+
expect(err.statusCode).toBe(500);
27+
expect(err.url).toBe('https://api.hiro.so/v2/info');
28+
expect(err.responseBody).toBe('Internal Server Error');
29+
});
30+
31+
it('FeeEstimationError includes status and body', () => {
32+
const err = new FeeEstimationError('Fee estimation failed 400', 400, 'bad request');
33+
expect(err).toBeInstanceOf(PlaystacksError);
34+
expect(err.name).toBe('FeeEstimationError');
35+
expect(err.statusCode).toBe(400);
36+
expect(err.responseBody).toBe('bad request');
37+
});
38+
39+
it('BroadcastError includes reason', () => {
40+
const err = new BroadcastError('Transaction broadcast failed', '{"error":"ConflictingNonceInMempool"}');
41+
expect(err).toBeInstanceOf(PlaystacksError);
42+
expect(err.name).toBe('BroadcastError');
43+
expect(err.reason).toBe('{"error":"ConflictingNonceInMempool"}');
44+
});
45+
46+
it('ConfirmationError includes txid and timeout', () => {
47+
const err = new ConfirmationError('did not confirm', '0xabc', 120_000);
48+
expect(err).toBeInstanceOf(PlaystacksError);
49+
expect(err.name).toBe('ConfirmationError');
50+
expect(err.txid).toBe('0xabc');
51+
expect(err.timeoutMs).toBe(120_000);
52+
});
53+
54+
it('UserRejectionError has defaults', () => {
55+
const err = new UserRejectionError();
56+
expect(err).toBeInstanceOf(PlaystacksError);
57+
expect(err.name).toBe('UserRejectionError');
58+
expect(err.message).toBe('User rejected the request');
59+
expect(err.code).toBe(4001);
60+
});
61+
62+
it('ConfigurationError for invalid config', () => {
63+
const err = new ConfigurationError('Unknown network "foo"');
64+
expect(err).toBeInstanceOf(PlaystacksError);
65+
expect(err.name).toBe('ConfigurationError');
66+
expect(err.message).toBe('Unknown network "foo"');
67+
});
68+
69+
it('errors support cause chaining via ErrorOptions', () => {
70+
const cause = new Error('original fetch error');
71+
const err = new NetworkError('API error', 500, '/v2/info', undefined, { cause });
72+
expect(err.cause).toBe(cause);
73+
74+
const configErr = new ConfigurationError('bad key', { cause });
75+
expect(configErr.cause).toBe(cause);
76+
});
77+
78+
it('errors can be caught by base class', () => {
79+
const errors = [
80+
new NetworkError('net', 500, '/'),
81+
new FeeEstimationError('fee'),
82+
new BroadcastError('broadcast'),
83+
new ConfirmationError('confirm', '0x1', 1000),
84+
new UserRejectionError(),
85+
new ConfigurationError('config'),
86+
];
87+
for (const err of errors) {
88+
expect(err).toBeInstanceOf(PlaystacksError);
89+
expect(err).toBeInstanceOf(Error);
90+
}
91+
});
92+
});

0 commit comments

Comments
 (0)