Skip to content

Commit 73daf8e

Browse files
ryanioclaude
andauthored
feat: add error handling, validation, timeout, and verbose mode (#28)
- Handle network errors gracefully in CLI with structured JSON on stderr - Add NaN-guarding parseIntOption/parseFloatOption helpers; replace all raw parseInt/parseFloat calls across 8 command files - Fix empty object edge case in table formatter (Math.max on empty array) - Add configurable fetch timeout (default 30s) with --timeout CLI option - Add --verbose flag for request/response debugging on stderr - Extend post() to accept optional body and query params - Document cursor vs next parameter mapping in tokens API Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d9284eb commit 73daf8e

17 files changed

Lines changed: 322 additions & 50 deletions

src/cli.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
swapsCommand,
1212
tokensCommand,
1313
} from "./commands/index.js"
14+
import { parseIntOption } from "./parse.js"
1415

1516
const BANNER = `
1617
____ _____
@@ -34,12 +35,16 @@ program
3435
.option("--chain <chain>", "Default chain", "ethereum")
3536
.option("--format <format>", "Output format (json or table)", "json")
3637
.option("--base-url <url>", "API base URL")
38+
.option("--timeout <ms>", "Request timeout in milliseconds", "30000")
39+
.option("--verbose", "Log request and response info to stderr")
3740

3841
function getClient(): OpenSeaClient {
3942
const opts = program.opts<{
4043
apiKey?: string
4144
chain: string
4245
baseUrl?: string
46+
timeout: string
47+
verbose?: boolean
4348
}>()
4449

4550
const apiKey = opts.apiKey ?? process.env.OPENSEA_API_KEY
@@ -54,6 +59,8 @@ function getClient(): OpenSeaClient {
5459
apiKey,
5560
chain: opts.chain,
5661
baseUrl: opts.baseUrl,
62+
timeout: parseIntOption(opts.timeout, "--timeout"),
63+
verbose: opts.verbose,
5764
})
5865
}
5966

@@ -91,7 +98,19 @@ async function main() {
9198
)
9299
process.exit(1)
93100
}
94-
throw error
101+
const label =
102+
error instanceof TypeError ? "Network Error" : (error as Error).name
103+
console.error(
104+
JSON.stringify(
105+
{
106+
error: label,
107+
message: (error as Error).message,
108+
},
109+
null,
110+
2,
111+
),
112+
)
113+
process.exit(1)
95114
}
96115
}
97116

src/client.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,23 @@ import type { OpenSeaClientConfig } from "./types/index.js"
22

33
const DEFAULT_BASE_URL = "https://api.opensea.io"
44
const DEFAULT_GRAPHQL_URL = "https://gql.opensea.io/graphql"
5+
const DEFAULT_TIMEOUT_MS = 30_000
56

67
export class OpenSeaClient {
78
private apiKey: string
89
private baseUrl: string
910
private graphqlUrl: string
1011
private defaultChain: string
12+
private timeoutMs: number
13+
private verbose: boolean
1114

1215
constructor(config: OpenSeaClientConfig) {
1316
this.apiKey = config.apiKey
1417
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL
1518
this.graphqlUrl = config.graphqlUrl ?? DEFAULT_GRAPHQL_URL
1619
this.defaultChain = config.chain ?? "ethereum"
20+
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS
21+
this.verbose = config.verbose ?? false
1722
}
1823

1924
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
@@ -27,14 +32,23 @@ export class OpenSeaClient {
2732
}
2833
}
2934

35+
if (this.verbose) {
36+
console.error(`[verbose] GET ${url.toString()}`)
37+
}
38+
3039
const response = await fetch(url.toString(), {
3140
method: "GET",
3241
headers: {
3342
Accept: "application/json",
3443
"x-api-key": this.apiKey,
3544
},
45+
signal: AbortSignal.timeout(this.timeoutMs),
3646
})
3747

48+
if (this.verbose) {
49+
console.error(`[verbose] ${response.status} ${response.statusText}`)
50+
}
51+
3852
if (!response.ok) {
3953
const body = await response.text()
4054
throw new OpenSeaAPIError(response.status, body, path)
@@ -43,20 +57,48 @@ export class OpenSeaClient {
4357
return response.json() as Promise<T>
4458
}
4559

46-
async post<T>(path: string): Promise<T> {
60+
async post<T>(
61+
path: string,
62+
body?: Record<string, unknown>,
63+
params?: Record<string, unknown>,
64+
): Promise<T> {
4765
const url = new URL(`${this.baseUrl}${path}`)
4866

67+
if (params) {
68+
for (const [key, value] of Object.entries(params)) {
69+
if (value !== undefined && value !== null) {
70+
url.searchParams.set(key, String(value))
71+
}
72+
}
73+
}
74+
75+
const headers: Record<string, string> = {
76+
Accept: "application/json",
77+
"x-api-key": this.apiKey,
78+
}
79+
80+
if (body) {
81+
headers["Content-Type"] = "application/json"
82+
}
83+
84+
if (this.verbose) {
85+
console.error(`[verbose] POST ${url.toString()}`)
86+
}
87+
4988
const response = await fetch(url.toString(), {
5089
method: "POST",
51-
headers: {
52-
Accept: "application/json",
53-
"x-api-key": this.apiKey,
54-
},
90+
headers,
91+
body: body ? JSON.stringify(body) : undefined,
92+
signal: AbortSignal.timeout(this.timeoutMs),
5593
})
5694

95+
if (this.verbose) {
96+
console.error(`[verbose] ${response.status} ${response.statusText}`)
97+
}
98+
5799
if (!response.ok) {
58-
const body = await response.text()
59-
throw new OpenSeaAPIError(response.status, body, path)
100+
const text = await response.text()
101+
throw new OpenSeaAPIError(response.status, text, path)
60102
}
61103

62104
return response.json() as Promise<T>
@@ -66,6 +108,10 @@ export class OpenSeaClient {
66108
query: string,
67109
variables?: Record<string, unknown>,
68110
): Promise<T> {
111+
if (this.verbose) {
112+
console.error(`[verbose] POST ${this.graphqlUrl}`)
113+
}
114+
69115
const response = await fetch(this.graphqlUrl, {
70116
method: "POST",
71117
headers: {
@@ -74,8 +120,13 @@ export class OpenSeaClient {
74120
"x-api-key": this.apiKey,
75121
},
76122
body: JSON.stringify({ query, variables }),
123+
signal: AbortSignal.timeout(this.timeoutMs),
77124
})
78125

126+
if (this.verbose) {
127+
console.error(`[verbose] ${response.status} ${response.statusText}`)
128+
}
129+
79130
if (!response.ok) {
80131
const body = await response.text()
81132
throw new OpenSeaAPIError(response.status, body, "graphql")

src/commands/collections.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from "commander"
22
import type { OpenSeaClient } from "../client.js"
33
import { formatOutput } from "../output.js"
4+
import { parseIntOption } from "../parse.js"
45
import type {
56
Chain,
67
Collection,
@@ -57,7 +58,7 @@ export function collectionsCommand(
5758
order_by: options.orderBy as CollectionOrderBy | undefined,
5859
creator_username: options.creator,
5960
include_hidden: options.includeHidden,
60-
limit: Number.parseInt(options.limit, 10),
61+
limit: parseIntOption(options.limit, "--limit"),
6162
next: options.next,
6263
})
6364
console.log(formatOutput(result, getFormat()))

src/commands/events.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from "commander"
22
import type { OpenSeaClient } from "../client.js"
33
import { formatOutput } from "../output.js"
4+
import { parseIntOption } from "../parse.js"
45
import type { AssetEvent } from "../types/index.js"
56

67
export function eventsCommand(
@@ -36,12 +37,14 @@ export function eventsCommand(
3637
next?: string
3738
}>("/api/v2/events", {
3839
event_type: options.eventType,
39-
after: options.after ? Number.parseInt(options.after, 10) : undefined,
40+
after: options.after
41+
? parseIntOption(options.after, "--after")
42+
: undefined,
4043
before: options.before
41-
? Number.parseInt(options.before, 10)
44+
? parseIntOption(options.before, "--before")
4245
: undefined,
4346
chain: options.chain,
44-
limit: Number.parseInt(options.limit, 10),
47+
limit: parseIntOption(options.limit, "--limit"),
4548
next: options.next,
4649
})
4750
console.log(formatOutput(result, getFormat()))
@@ -73,7 +76,7 @@ export function eventsCommand(
7376
}>(`/api/v2/events/accounts/${address}`, {
7477
event_type: options.eventType,
7578
chain: options.chain,
76-
limit: Number.parseInt(options.limit, 10),
79+
limit: parseIntOption(options.limit, "--limit"),
7780
next: options.next,
7881
})
7982
console.log(formatOutput(result, getFormat()))
@@ -102,7 +105,7 @@ export function eventsCommand(
102105
next?: string
103106
}>(`/api/v2/events/collection/${slug}`, {
104107
event_type: options.eventType,
105-
limit: Number.parseInt(options.limit, 10),
108+
limit: parseIntOption(options.limit, "--limit"),
106109
next: options.next,
107110
})
108111
console.log(formatOutput(result, getFormat()))
@@ -137,7 +140,7 @@ export function eventsCommand(
137140
`/api/v2/events/chain/${chain}/contract/${contract}/nfts/${tokenId}`,
138141
{
139142
event_type: options.eventType,
140-
limit: Number.parseInt(options.limit, 10),
143+
limit: parseIntOption(options.limit, "--limit"),
141144
next: options.next,
142145
},
143146
)

src/commands/listings.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from "commander"
22
import type { OpenSeaClient } from "../client.js"
33
import { formatOutput } from "../output.js"
4+
import { parseIntOption } from "../parse.js"
45
import type { Listing } from "../types/index.js"
56

67
export function listingsCommand(
@@ -22,7 +23,7 @@ export function listingsCommand(
2223
listings: Listing[]
2324
next?: string
2425
}>(`/api/v2/listings/collection/${collection}/all`, {
25-
limit: Number.parseInt(options.limit, 10),
26+
limit: parseIntOption(options.limit, "--limit"),
2627
next: options.next,
2728
})
2829
console.log(formatOutput(result, getFormat()))
@@ -42,7 +43,7 @@ export function listingsCommand(
4243
listings: Listing[]
4344
next?: string
4445
}>(`/api/v2/listings/collection/${collection}/best`, {
45-
limit: Number.parseInt(options.limit, 10),
46+
limit: parseIntOption(options.limit, "--limit"),
4647
next: options.next,
4748
})
4849
console.log(formatOutput(result, getFormat()))

src/commands/nfts.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from "commander"
22
import type { OpenSeaClient } from "../client.js"
33
import { formatOutput } from "../output.js"
4+
import { parseIntOption } from "../parse.js"
45
import type { Contract, NFT } from "../types/index.js"
56

67
export function nftsCommand(
@@ -34,7 +35,7 @@ export function nftsCommand(
3435
const result = await client.get<{ nfts: NFT[]; next?: string }>(
3536
`/api/v2/collection/${slug}/nfts`,
3637
{
37-
limit: Number.parseInt(options.limit, 10),
38+
limit: parseIntOption(options.limit, "--limit"),
3839
next: options.next,
3940
},
4041
)
@@ -58,7 +59,7 @@ export function nftsCommand(
5859
const result = await client.get<{ nfts: NFT[]; next?: string }>(
5960
`/api/v2/chain/${chain}/contract/${contract}/nfts`,
6061
{
61-
limit: Number.parseInt(options.limit, 10),
62+
limit: parseIntOption(options.limit, "--limit"),
6263
next: options.next,
6364
},
6465
)
@@ -83,7 +84,7 @@ export function nftsCommand(
8384
const result = await client.get<{ nfts: NFT[]; next?: string }>(
8485
`/api/v2/chain/${chain}/account/${address}/nfts`,
8586
{
86-
limit: Number.parseInt(options.limit, 10),
87+
limit: parseIntOption(options.limit, "--limit"),
8788
next: options.next,
8889
},
8990
)

src/commands/offers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from "commander"
22
import type { OpenSeaClient } from "../client.js"
33
import { formatOutput } from "../output.js"
4+
import { parseIntOption } from "../parse.js"
45
import type { Offer } from "../types/index.js"
56

67
export function offersCommand(
@@ -22,7 +23,7 @@ export function offersCommand(
2223
offers: Offer[]
2324
next?: string
2425
}>(`/api/v2/offers/collection/${collection}/all`, {
25-
limit: Number.parseInt(options.limit, 10),
26+
limit: parseIntOption(options.limit, "--limit"),
2627
next: options.next,
2728
})
2829
console.log(formatOutput(result, getFormat()))
@@ -42,7 +43,7 @@ export function offersCommand(
4243
offers: Offer[]
4344
next?: string
4445
}>(`/api/v2/offers/collection/${collection}`, {
45-
limit: Number.parseInt(options.limit, 10),
46+
limit: parseIntOption(options.limit, "--limit"),
4647
next: options.next,
4748
})
4849
console.log(formatOutput(result, getFormat()))
@@ -87,7 +88,7 @@ export function offersCommand(
8788
}>(`/api/v2/offers/collection/${collection}/traits`, {
8889
type: options.type,
8990
value: options.value,
90-
limit: Number.parseInt(options.limit, 10),
91+
limit: parseIntOption(options.limit, "--limit"),
9192
next: options.next,
9293
})
9394
console.log(formatOutput(result, getFormat()))

src/commands/search.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from "commander"
22
import type { OpenSeaClient } from "../client.js"
33
import { formatOutput } from "../output.js"
4+
import { parseIntOption } from "../parse.js"
45
import {
56
SEARCH_ACCOUNTS_QUERY,
67
SEARCH_COLLECTIONS_QUERY,
@@ -35,7 +36,7 @@ export function searchCommand(
3536
collectionsByQuery: SearchCollectionResult[]
3637
}>(SEARCH_COLLECTIONS_QUERY, {
3738
query,
38-
limit: Number.parseInt(options.limit, 10),
39+
limit: parseIntOption(options.limit, "--limit"),
3940
chains: options.chains?.split(","),
4041
})
4142
console.log(formatOutput(result.collectionsByQuery, getFormat()))
@@ -60,7 +61,7 @@ export function searchCommand(
6061
}>(SEARCH_NFTS_QUERY, {
6162
query,
6263
collectionSlug: options.collection,
63-
limit: Number.parseInt(options.limit, 10),
64+
limit: parseIntOption(options.limit, "--limit"),
6465
chains: options.chains?.split(","),
6566
})
6667
console.log(formatOutput(result.itemsByQuery, getFormat()))
@@ -80,7 +81,7 @@ export function searchCommand(
8081
currenciesByQuery: SearchTokenResult[]
8182
}>(SEARCH_TOKENS_QUERY, {
8283
query,
83-
limit: Number.parseInt(options.limit, 10),
84+
limit: parseIntOption(options.limit, "--limit"),
8485
chain: options.chain,
8586
})
8687
console.log(formatOutput(result.currenciesByQuery, getFormat()))
@@ -98,7 +99,7 @@ export function searchCommand(
9899
accountsByQuery: SearchAccountResult[]
99100
}>(SEARCH_ACCOUNTS_QUERY, {
100101
query,
101-
limit: Number.parseInt(options.limit, 10),
102+
limit: parseIntOption(options.limit, "--limit"),
102103
})
103104
console.log(formatOutput(result.accountsByQuery, getFormat()))
104105
})

0 commit comments

Comments
 (0)