Skip to content

Commit f3d2c7a

Browse files
ckorhonendevin-ai-integration[bot]claude
authored
feat: add opensea health diagnostic command (DIS-144) (#35)
* feat: add opensea health diagnostic command (DIS-144) Co-Authored-By: Chris K <ckorhonen@gmail.com> * fix: address code review feedback on health command (DIS-144) - Fix misleading success message: says 'Connectivity is working' instead of claiming API key validation (endpoint returns 200 for any key) - Extract shared checkHealth() into src/health.ts to DRY up duplicated logic between CLI command and SDK HealthAPI - Change 'type HealthResult' to 'interface HealthResult' for consistency with other types in src/types/ - Add getApiKeyPrefix to MockClient type instead of using Record cast - Fix SDK test: use vi.importActual for OpenSeaAPIError so instanceof works correctly, enabling auth/API error branch coverage - Add SDK tests for auth error (401) and API error (500) branches Co-Authored-By: Chris K <ckorhonen@gmail.com> * fix: validate API key authentication in health check The health check now performs two steps: 1. Connectivity check via /api/v2/collections (public endpoint) 2. Auth validation via /api/v2/listings (requires valid API key) Previously the health check only hit a public endpoint that returned 200 regardless of API key validity, giving false "ok" results for invalid keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: biome formatting and stale comment in health check Co-Authored-By: Chris K <ckorhonen@gmail.com> * fix: guard short API keys, add 429 rate-limit handling with exit code 3 - getApiKeyPrefix() returns '***' for keys shorter than 8 chars - checkHealth() detects 429 responses and sets rate_limited flag - CLI exits with code 3 on rate limiting (vs 1 for other errors) - HealthResult interface gains rate_limited boolean field - Added tests for 429 handling in both CLI and SDK Co-Authored-By: Chris K <ckorhonen@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8f2fb5a commit f3d2c7a

12 files changed

Lines changed: 381 additions & 3 deletions

File tree

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
accountsCommand,
55
collectionsCommand,
66
eventsCommand,
7+
healthCommand,
78
listingsCommand,
89
nftsCommand,
910
offersCommand,
@@ -109,6 +110,7 @@ program.addCommand(accountsCommand(getClient, getFormat))
109110
program.addCommand(tokensCommand(getClient, getFormat))
110111
program.addCommand(searchCommand(getClient, getFormat))
111112
program.addCommand(swapsCommand(getClient, getFormat))
113+
program.addCommand(healthCommand(getClient, getFormat))
112114

113115
async function main() {
114116
try {

src/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ export class OpenSeaClient {
109109
getDefaultChain(): string {
110110
return this.defaultChain
111111
}
112+
113+
getApiKeyPrefix(): string {
114+
if (this.apiKey.length < 8) return "***"
115+
return `${this.apiKey.slice(0, 4)}...`
116+
}
112117
}
113118

114119
export class OpenSeaAPIError extends Error {

src/commands/health.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Command } from "commander"
2+
import type { OpenSeaClient } from "../client.js"
3+
import { checkHealth } from "../health.js"
4+
import type { OutputFormat } from "../output.js"
5+
import { formatOutput } from "../output.js"
6+
7+
export function healthCommand(
8+
getClient: () => OpenSeaClient,
9+
getFormat: () => OutputFormat,
10+
): Command {
11+
const cmd = new Command("health")
12+
.description("Check API connectivity and authentication")
13+
.action(async () => {
14+
const client = getClient()
15+
const result = await checkHealth(client)
16+
console.log(formatOutput(result, getFormat()))
17+
if (result.status === "error") {
18+
process.exit(result.rate_limited ? 3 : 1)
19+
}
20+
})
21+
22+
return cmd
23+
}

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { accountsCommand } from "./accounts.js"
22
export { collectionsCommand } from "./collections.js"
33
export { eventsCommand } from "./events.js"
4+
export { healthCommand } from "./health.js"
45
export { listingsCommand } from "./listings.js"
56
export { nftsCommand } from "./nfts.js"
67
export { offersCommand } from "./offers.js"

src/health.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { OpenSeaAPIError, type OpenSeaClient } from "./client.js"
2+
import type { HealthResult } from "./types/index.js"
3+
4+
export async function checkHealth(
5+
client: OpenSeaClient,
6+
): Promise<HealthResult> {
7+
const keyPrefix = client.getApiKeyPrefix()
8+
9+
// Step 1: Check basic connectivity with a public endpoint
10+
try {
11+
await client.get("/api/v2/collections", { limit: 1 })
12+
} catch (error) {
13+
let message: string
14+
if (error instanceof OpenSeaAPIError) {
15+
message =
16+
error.statusCode === 429
17+
? "Rate limited: too many requests"
18+
: `API error (${error.statusCode}): ${error.responseBody}`
19+
} else {
20+
message = `Network error: ${(error as Error).message}`
21+
}
22+
return {
23+
status: "error",
24+
key_prefix: keyPrefix,
25+
authenticated: false,
26+
rate_limited:
27+
error instanceof OpenSeaAPIError && error.statusCode === 429,
28+
message,
29+
}
30+
}
31+
32+
// Step 2: Validate authentication with an endpoint that requires a valid API key
33+
try {
34+
await client.get("/api/v2/listings/collection/boredapeyachtclub/all", {
35+
limit: 1,
36+
})
37+
return {
38+
status: "ok",
39+
key_prefix: keyPrefix,
40+
authenticated: true,
41+
rate_limited: false,
42+
message: "Connectivity and authentication are working",
43+
}
44+
} catch (error) {
45+
if (error instanceof OpenSeaAPIError) {
46+
if (error.statusCode === 429) {
47+
return {
48+
status: "error",
49+
key_prefix: keyPrefix,
50+
authenticated: false,
51+
rate_limited: true,
52+
message: "Rate limited: too many requests",
53+
}
54+
}
55+
if (error.statusCode === 401 || error.statusCode === 403) {
56+
return {
57+
status: "error",
58+
key_prefix: keyPrefix,
59+
authenticated: false,
60+
rate_limited: false,
61+
message: `Authentication failed (${error.statusCode}): invalid API key`,
62+
}
63+
}
64+
}
65+
// Non-auth error on listings endpoint — connectivity works but auth is unverified
66+
return {
67+
status: "ok",
68+
key_prefix: keyPrefix,
69+
authenticated: false,
70+
rate_limited: false,
71+
message:
72+
"Connectivity is working but authentication could not be verified",
73+
}
74+
}
75+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { OpenSeaAPIError, OpenSeaClient } from "./client.js"
2+
export { checkHealth } from "./health.js"
23
export type { OutputFormat } from "./output.js"
34
export { formatOutput } from "./output.js"
45
export { OpenSeaCLI } from "./sdk.js"

src/sdk.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { OpenSeaClient } from "./client.js"
2+
import { checkHealth } from "./health.js"
23
import type {
34
Account,
45
AssetEvent,
@@ -9,6 +10,7 @@ import type {
910
Contract,
1011
EventType,
1112
GetTraitsResponse,
13+
HealthResult,
1214
Listing,
1315
NFT,
1416
Offer,
@@ -32,6 +34,7 @@ export class OpenSeaCLI {
3234
readonly tokens: TokensAPI
3335
readonly search: SearchAPI
3436
readonly swaps: SwapsAPI
37+
readonly health: HealthAPI
3538

3639
constructor(config: OpenSeaClientConfig) {
3740
this.client = new OpenSeaClient(config)
@@ -44,6 +47,7 @@ export class OpenSeaCLI {
4447
this.tokens = new TokensAPI(this.client)
4548
this.search = new SearchAPI(this.client)
4649
this.swaps = new SwapsAPI(this.client)
50+
this.health = new HealthAPI(this.client)
4751
}
4852
}
4953

@@ -384,3 +388,11 @@ class SwapsAPI {
384388
})
385389
}
386390
}
391+
392+
class HealthAPI {
393+
constructor(private client: OpenSeaClient) {}
394+
395+
async check(): Promise<HealthResult> {
396+
return checkHealth(this.client)
397+
}
398+
}

src/types/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,11 @@ export interface CommandOptions {
1414
format?: "json" | "table"
1515
raw?: boolean
1616
}
17+
18+
export interface HealthResult {
19+
status: "ok" | "error"
20+
key_prefix: string
21+
authenticated: boolean
22+
rate_limited: boolean
23+
message: string
24+
}

test/client.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,17 @@ describe("OpenSeaClient", () => {
218218
expect(client.getDefaultChain()).toBe("ethereum")
219219
})
220220
})
221+
222+
describe("getApiKeyPrefix", () => {
223+
it("returns first 4 characters followed by ellipsis", () => {
224+
expect(client.getApiKeyPrefix()).toBe("test...")
225+
})
226+
227+
it("masks short API keys", () => {
228+
const shortKeyClient = new OpenSeaClient({ apiKey: "ab" })
229+
expect(shortKeyClient.getApiKeyPrefix()).toBe("***")
230+
})
231+
})
221232
})
222233

223234
describe("OpenSeaAPIError", () => {

test/commands/health.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2+
import { OpenSeaAPIError } from "../../src/client.js"
3+
import { healthCommand } from "../../src/commands/health.js"
4+
import { type CommandTestContext, createCommandTestContext } from "../mocks.js"
5+
6+
describe("healthCommand", () => {
7+
let ctx: CommandTestContext
8+
9+
beforeEach(() => {
10+
ctx = createCommandTestContext()
11+
})
12+
13+
afterEach(() => {
14+
vi.restoreAllMocks()
15+
})
16+
17+
it("creates command with correct name", () => {
18+
const cmd = healthCommand(ctx.getClient, ctx.getFormat)
19+
expect(cmd.name()).toBe("health")
20+
})
21+
22+
it("outputs ok status when both connectivity and auth succeed", async () => {
23+
ctx.mockClient.get.mockResolvedValue({})
24+
25+
const cmd = healthCommand(ctx.getClient, ctx.getFormat)
26+
await cmd.parseAsync([], { from: "user" })
27+
28+
expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/collections", {
29+
limit: 1,
30+
})
31+
expect(ctx.mockClient.get).toHaveBeenCalledWith(
32+
"/api/v2/listings/collection/boredapeyachtclub/all",
33+
{
34+
limit: 1,
35+
},
36+
)
37+
const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
38+
expect(output.status).toBe("ok")
39+
expect(output.key_prefix).toBe("test...")
40+
expect(output.authenticated).toBe(true)
41+
expect(output.message).toBe("Connectivity and authentication are working")
42+
})
43+
44+
it("outputs error status when connectivity fails", async () => {
45+
ctx.mockClient.get.mockRejectedValue(
46+
new OpenSeaAPIError(500, "Internal Server Error", "/api/v2/collections"),
47+
)
48+
49+
const mockExit = vi
50+
.spyOn(process, "exit")
51+
.mockImplementation(() => undefined as never)
52+
53+
const cmd = healthCommand(ctx.getClient, ctx.getFormat)
54+
await cmd.parseAsync([], { from: "user" })
55+
56+
const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
57+
expect(output.status).toBe("error")
58+
expect(output.authenticated).toBe(false)
59+
expect(output.message).toContain("API error (500)")
60+
expect(mockExit).toHaveBeenCalledWith(1)
61+
})
62+
63+
it("outputs error status when auth fails (401)", async () => {
64+
ctx.mockClient.get
65+
.mockResolvedValueOnce({}) // connectivity ok
66+
.mockRejectedValueOnce(
67+
new OpenSeaAPIError(
68+
401,
69+
"Unauthorized",
70+
"/api/v2/listings/collection/boredapeyachtclub/all",
71+
),
72+
)
73+
74+
const mockExit = vi
75+
.spyOn(process, "exit")
76+
.mockImplementation(() => undefined as never)
77+
78+
const cmd = healthCommand(ctx.getClient, ctx.getFormat)
79+
await cmd.parseAsync([], { from: "user" })
80+
81+
const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
82+
expect(output.status).toBe("error")
83+
expect(output.authenticated).toBe(false)
84+
expect(output.message).toContain("Authentication failed (401)")
85+
expect(mockExit).toHaveBeenCalledWith(1)
86+
})
87+
88+
it("outputs error status on network errors", async () => {
89+
ctx.mockClient.get.mockRejectedValue(new TypeError("fetch failed"))
90+
91+
const mockExit = vi
92+
.spyOn(process, "exit")
93+
.mockImplementation(() => undefined as never)
94+
95+
const cmd = healthCommand(ctx.getClient, ctx.getFormat)
96+
await cmd.parseAsync([], { from: "user" })
97+
98+
const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
99+
expect(output.status).toBe("error")
100+
expect(output.message).toContain("Network error: fetch failed")
101+
expect(mockExit).toHaveBeenCalledWith(1)
102+
})
103+
104+
it("reports ok with unverified auth when listings endpoint has non-auth error", async () => {
105+
ctx.mockClient.get
106+
.mockResolvedValueOnce({}) // connectivity ok
107+
.mockRejectedValueOnce(
108+
new OpenSeaAPIError(
109+
500,
110+
"Server Error",
111+
"/api/v2/listings/collection/boredapeyachtclub/all",
112+
),
113+
)
114+
115+
const cmd = healthCommand(ctx.getClient, ctx.getFormat)
116+
await cmd.parseAsync([], { from: "user" })
117+
118+
const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
119+
expect(output.status).toBe("ok")
120+
expect(output.authenticated).toBe(false)
121+
expect(output.message).toContain("could not be verified")
122+
})
123+
124+
it("exits with code 3 on rate limit (429)", async () => {
125+
ctx.mockClient.get.mockRejectedValue(
126+
new OpenSeaAPIError(429, "Too Many Requests", "/api/v2/collections"),
127+
)
128+
129+
const mockExit = vi
130+
.spyOn(process, "exit")
131+
.mockImplementation(() => undefined as never)
132+
133+
const cmd = healthCommand(ctx.getClient, ctx.getFormat)
134+
await cmd.parseAsync([], { from: "user" })
135+
136+
const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
137+
expect(output.status).toBe("error")
138+
expect(output.rate_limited).toBe(true)
139+
expect(output.message).toContain("Rate limited")
140+
expect(mockExit).toHaveBeenCalledWith(3)
141+
})
142+
})

0 commit comments

Comments
 (0)