Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,9 @@ Key aspects:
### Error Handling

- API errors are wrapped in `OpenSeaAPIError` (includes status code, response body, path).
- CLI catches `OpenSeaAPIError` and outputs structured JSON to stderr, then exits with code 1.
- CLI catches `OpenSeaAPIError` and outputs structured JSON to stderr, then exits with code 1 (or code 3 for rate limiting).
- Authentication errors (missing API key) exit with code 2.
- Exit codes: 0 = success, 1 = API error, 2 = auth error.
- Exit codes: 0 = success, 1 = API error, 2 = auth error, 3 = rate limited (HTTP 429).

## Design Rules

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ console.log(formatToon(data))
- `0` - Success
- `1` - API error
- `2` - Authentication error
- `3` - Rate limited (HTTP 429)

## Requirements

Expand Down
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ async function main() {
await program.parseAsync(process.argv)
} catch (error) {
if (error instanceof OpenSeaAPIError) {
const isRateLimited = error.statusCode === 429
console.error(
JSON.stringify(
{
error: "API Error",
error: isRateLimited ? "Rate Limited" : "API Error",
status: error.statusCode,
path: error.path,
message: error.responseBody,
Expand All @@ -99,7 +100,7 @@ async function main() {
2,
),
)
process.exit(1)
process.exit(isRateLimited ? 3 : 1)
}
const label =
error instanceof TypeError ? "Network Error" : (error as Error).name
Expand Down
41 changes: 41 additions & 0 deletions test/cli-api-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Command } from "commander"
import { afterAll, expect, it, vi } from "vitest"
import { OpenSeaAPIError } from "../src/client.js"

vi.mock("../src/commands/index.js", () => ({
accountsCommand: () => new Command("accounts"),
collectionsCommand: () => new Command("collections"),
eventsCommand: () => new Command("events"),
listingsCommand: () => new Command("listings"),
nftsCommand: () => new Command("nfts"),
offersCommand: () => new Command("offers"),
searchCommand: () => new Command("search"),
swapsCommand: () => new Command("swaps"),
tokensCommand: () => new Command("tokens"),
}))

const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})

vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
new OpenSeaAPIError(404, "Not Found", "/api/v2/missing"),
)

afterAll(() => {
vi.restoreAllMocks()
})

it("exits with code 1 and 'API Error' label on non-429 API error", async () => {
await import("../src/cli.js")
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})

expect(exitSpy).toHaveBeenCalledWith(1)
const output = stderrSpy.mock.calls[0][0] as string
const parsed = JSON.parse(output)
expect(parsed.error).toBe("API Error")
expect(parsed.status).toBe(404)
})
39 changes: 39 additions & 0 deletions test/cli-network-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Command } from "commander"
import { afterAll, expect, it, vi } from "vitest"

vi.mock("../src/commands/index.js", () => ({
accountsCommand: () => new Command("accounts"),
collectionsCommand: () => new Command("collections"),
eventsCommand: () => new Command("events"),
listingsCommand: () => new Command("listings"),
nftsCommand: () => new Command("nfts"),
offersCommand: () => new Command("offers"),
searchCommand: () => new Command("search"),
swapsCommand: () => new Command("swaps"),
tokensCommand: () => new Command("tokens"),
}))

const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})

vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
new TypeError("fetch failed"),
)

afterAll(() => {
vi.restoreAllMocks()
})

it("exits with code 1 on non-API errors", async () => {
await import("../src/cli.js")
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})

expect(exitSpy).toHaveBeenCalledWith(1)
const output = stderrSpy.mock.calls[0][0] as string
const parsed = JSON.parse(output)
expect(parsed.error).toBe("Network Error")
})
43 changes: 43 additions & 0 deletions test/cli-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Command } from "commander"
import { afterAll, expect, it, vi } from "vitest"
import { OpenSeaAPIError } from "../src/client.js"

vi.mock("../src/commands/index.js", () => ({
accountsCommand: () => new Command("accounts"),
collectionsCommand: () => new Command("collections"),
eventsCommand: () => new Command("events"),
listingsCommand: () => new Command("listings"),
nftsCommand: () => new Command("nfts"),
offersCommand: () => new Command("offers"),
searchCommand: () => new Command("search"),
swapsCommand: () => new Command("swaps"),
tokensCommand: () => new Command("tokens"),
}))

const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})

vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
new OpenSeaAPIError(429, "Rate limit exceeded", "/api/v2/test"),
)

afterAll(() => {
vi.restoreAllMocks()
})

it("exits with code 3 and 'Rate Limited' label on 429 error", async () => {
await import("../src/cli.js")
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})

expect(exitSpy).toHaveBeenCalledWith(3)
const output = stderrSpy.mock.calls[0][0] as string
const parsed = JSON.parse(output)
expect(parsed.error).toBe("Rate Limited")
expect(parsed.status).toBe(429)
expect(parsed.path).toBe("/api/v2/test")
expect(parsed.message).toBe("Rate limit exceeded")
})