Skip to content

Commit 3861456

Browse files
feat: add exit code 3 for rate limiting (DIS-146)
When the OpenSea API returns HTTP 429, the CLI now exits with code 3 and labels the error as 'Rate Limited' instead of the generic 'API Error'. This allows agents to programmatically detect rate limits and implement wait-and-retry behavior. Exit codes: 0=success, 1=API error, 2=auth error, 3=rate limited. Co-Authored-By: Chris K <ckorhonen@gmail.com>
1 parent 7a06fc4 commit 3861456

6 files changed

Lines changed: 129 additions & 4 deletions

File tree

.agents/rules.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,9 @@ Key aspects:
160160
### Error Handling
161161

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

167167
## Design Rules
168168

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ console.log(formatToon(data))
169169
- `0` - Success
170170
- `1` - API error
171171
- `2` - Authentication error
172+
- `3` - Rate limited (HTTP 429)
172173

173174
## Requirements
174175

src/cli.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,11 @@ async function main() {
8787
await program.parseAsync(process.argv)
8888
} catch (error) {
8989
if (error instanceof OpenSeaAPIError) {
90+
const isRateLimited = error.statusCode === 429
9091
console.error(
9192
JSON.stringify(
9293
{
93-
error: "API Error",
94+
error: isRateLimited ? "Rate Limited" : "API Error",
9495
status: error.statusCode,
9596
path: error.path,
9697
message: error.responseBody,
@@ -99,7 +100,7 @@ async function main() {
99100
2,
100101
),
101102
)
102-
process.exit(1)
103+
process.exit(isRateLimited ? 3 : 1)
103104
}
104105
const label =
105106
error instanceof TypeError ? "Network Error" : (error as Error).name

test/cli-api-error.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Command } from "commander"
2+
import { afterAll, expect, it, vi } from "vitest"
3+
import { OpenSeaAPIError } from "../src/client.js"
4+
5+
vi.mock("../src/commands/index.js", () => ({
6+
accountsCommand: () => new Command("accounts"),
7+
collectionsCommand: () => new Command("collections"),
8+
eventsCommand: () => new Command("events"),
9+
listingsCommand: () => new Command("listings"),
10+
nftsCommand: () => new Command("nfts"),
11+
offersCommand: () => new Command("offers"),
12+
searchCommand: () => new Command("search"),
13+
swapsCommand: () => new Command("swaps"),
14+
tokensCommand: () => new Command("tokens"),
15+
}))
16+
17+
const exitSpy = vi
18+
.spyOn(process, "exit")
19+
.mockImplementation(() => undefined as never)
20+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})
21+
22+
vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
23+
new OpenSeaAPIError(404, "Not Found", "/api/v2/missing"),
24+
)
25+
26+
afterAll(() => {
27+
vi.restoreAllMocks()
28+
})
29+
30+
it("exits with code 1 and 'API Error' label on non-429 API error", async () => {
31+
await import("../src/cli.js")
32+
await vi.waitFor(() => {
33+
expect(exitSpy).toHaveBeenCalled()
34+
})
35+
36+
expect(exitSpy).toHaveBeenCalledWith(1)
37+
const output = stderrSpy.mock.calls[0][0] as string
38+
const parsed = JSON.parse(output)
39+
expect(parsed.error).toBe("API Error")
40+
expect(parsed.status).toBe(404)
41+
})

test/cli-network-error.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Command } from "commander"
2+
import { afterAll, expect, it, vi } from "vitest"
3+
4+
vi.mock("../src/commands/index.js", () => ({
5+
accountsCommand: () => new Command("accounts"),
6+
collectionsCommand: () => new Command("collections"),
7+
eventsCommand: () => new Command("events"),
8+
listingsCommand: () => new Command("listings"),
9+
nftsCommand: () => new Command("nfts"),
10+
offersCommand: () => new Command("offers"),
11+
searchCommand: () => new Command("search"),
12+
swapsCommand: () => new Command("swaps"),
13+
tokensCommand: () => new Command("tokens"),
14+
}))
15+
16+
const exitSpy = vi
17+
.spyOn(process, "exit")
18+
.mockImplementation(() => undefined as never)
19+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})
20+
21+
vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
22+
new TypeError("fetch failed"),
23+
)
24+
25+
afterAll(() => {
26+
vi.restoreAllMocks()
27+
})
28+
29+
it("exits with code 1 on non-API errors", async () => {
30+
await import("../src/cli.js")
31+
await vi.waitFor(() => {
32+
expect(exitSpy).toHaveBeenCalled()
33+
})
34+
35+
expect(exitSpy).toHaveBeenCalledWith(1)
36+
const output = stderrSpy.mock.calls[0][0] as string
37+
const parsed = JSON.parse(output)
38+
expect(parsed.error).toBe("Network Error")
39+
})

test/cli-rate-limit.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Command } from "commander"
2+
import { afterAll, expect, it, vi } from "vitest"
3+
import { OpenSeaAPIError } from "../src/client.js"
4+
5+
vi.mock("../src/commands/index.js", () => ({
6+
accountsCommand: () => new Command("accounts"),
7+
collectionsCommand: () => new Command("collections"),
8+
eventsCommand: () => new Command("events"),
9+
listingsCommand: () => new Command("listings"),
10+
nftsCommand: () => new Command("nfts"),
11+
offersCommand: () => new Command("offers"),
12+
searchCommand: () => new Command("search"),
13+
swapsCommand: () => new Command("swaps"),
14+
tokensCommand: () => new Command("tokens"),
15+
}))
16+
17+
const exitSpy = vi
18+
.spyOn(process, "exit")
19+
.mockImplementation(() => undefined as never)
20+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})
21+
22+
vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
23+
new OpenSeaAPIError(429, "Rate limit exceeded", "/api/v2/test"),
24+
)
25+
26+
afterAll(() => {
27+
vi.restoreAllMocks()
28+
})
29+
30+
it("exits with code 3 and 'Rate Limited' label on 429 error", async () => {
31+
await import("../src/cli.js")
32+
await vi.waitFor(() => {
33+
expect(exitSpy).toHaveBeenCalled()
34+
})
35+
36+
expect(exitSpy).toHaveBeenCalledWith(3)
37+
const output = stderrSpy.mock.calls[0][0] as string
38+
const parsed = JSON.parse(output)
39+
expect(parsed.error).toBe("Rate Limited")
40+
expect(parsed.status).toBe(429)
41+
expect(parsed.path).toBe("/api/v2/test")
42+
expect(parsed.message).toBe("Rate limit exceeded")
43+
})

0 commit comments

Comments
 (0)