diff --git a/.agents/rules.md b/.agents/rules.md index 3fa9a0c..eb317ae 100644 --- a/.agents/rules.md +++ b/.agents/rules.md @@ -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 diff --git a/README.md b/README.md index 2351fb6..a3681e7 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,9 @@ console.log(formatToon(data)) ## Exit Codes - `0` - Success -- `1` - API error +- `1` - API error (non-429) - `2` - Authentication error +- `3` - Rate limited (HTTP 429) ## Requirements diff --git a/src/cli.ts b/src/cli.ts index dc1fbbb..5f96eef 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,10 @@ import { import type { OutputFormat } from "./output.js" import { parseIntOption } from "./parse.js" +const EXIT_API_ERROR = 1 +const EXIT_AUTH_ERROR = 2 +const EXIT_RATE_LIMITED = 3 + const BANNER = ` ____ _____ / __ \\ / ____| @@ -53,7 +57,7 @@ function getClient(): OpenSeaClient { console.error( "Error: API key required. Use --api-key or set OPENSEA_API_KEY environment variable.", ) - process.exit(2) + process.exit(EXIT_AUTH_ERROR) } return new OpenSeaClient({ @@ -87,10 +91,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, @@ -99,7 +104,7 @@ async function main() { 2, ), ) - process.exit(1) + process.exit(isRateLimited ? EXIT_RATE_LIMITED : EXIT_API_ERROR) } const label = error instanceof TypeError ? "Network Error" : (error as Error).name @@ -113,7 +118,7 @@ async function main() { 2, ), ) - process.exit(1) + process.exit(EXIT_API_ERROR) } } diff --git a/test/cli-api-error.test.ts b/test/cli-api-error.test.ts new file mode 100644 index 0000000..fbf621e --- /dev/null +++ b/test/cli-api-error.test.ts @@ -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) +}) diff --git a/test/cli-network-error.test.ts b/test/cli-network-error.test.ts new file mode 100644 index 0000000..5d4266d --- /dev/null +++ b/test/cli-network-error.test.ts @@ -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") +}) diff --git a/test/cli-rate-limit.test.ts b/test/cli-rate-limit.test.ts new file mode 100644 index 0000000..bbadf0d --- /dev/null +++ b/test/cli-rate-limit.test.ts @@ -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") +})