Skip to content

Commit eb3ef4a

Browse files
feat: add exit code 3 for rate limiting (DIS-146) (#36)
* 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> * refactor: extract exit code constants and clarify README (DIS-146) Address reviewer feedback: - Extract EXIT_API_ERROR, EXIT_AUTH_ERROR, EXIT_RATE_LIMITED constants - Clarify README exit code 1 as 'API error (non-429)' Co-Authored-By: Chris K <ckorhonen@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent c7d51b9 commit eb3ef4a

6 files changed

Lines changed: 136 additions & 7 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,9 @@ console.log(formatToon(data))
167167
## Exit Codes
168168

169169
- `0` - Success
170-
- `1` - API error
170+
- `1` - API error (non-429)
171171
- `2` - Authentication error
172+
- `3` - Rate limited (HTTP 429)
172173

173174
## Requirements
174175

src/cli.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
import type { OutputFormat } from "./output.js"
1515
import { parseIntOption } from "./parse.js"
1616

17+
const EXIT_API_ERROR = 1
18+
const EXIT_AUTH_ERROR = 2
19+
const EXIT_RATE_LIMITED = 3
20+
1721
const BANNER = `
1822
____ _____
1923
/ __ \\ / ____|
@@ -53,7 +57,7 @@ function getClient(): OpenSeaClient {
5357
console.error(
5458
"Error: API key required. Use --api-key or set OPENSEA_API_KEY environment variable.",
5559
)
56-
process.exit(2)
60+
process.exit(EXIT_AUTH_ERROR)
5761
}
5862

5963
return new OpenSeaClient({
@@ -87,10 +91,11 @@ async function main() {
8791
await program.parseAsync(process.argv)
8892
} catch (error) {
8993
if (error instanceof OpenSeaAPIError) {
94+
const isRateLimited = error.statusCode === 429
9095
console.error(
9196
JSON.stringify(
9297
{
93-
error: "API Error",
98+
error: isRateLimited ? "Rate Limited" : "API Error",
9499
status: error.statusCode,
95100
path: error.path,
96101
message: error.responseBody,
@@ -99,7 +104,7 @@ async function main() {
99104
2,
100105
),
101106
)
102-
process.exit(1)
107+
process.exit(isRateLimited ? EXIT_RATE_LIMITED : EXIT_API_ERROR)
103108
}
104109
const label =
105110
error instanceof TypeError ? "Network Error" : (error as Error).name
@@ -113,7 +118,7 @@ async function main() {
113118
2,
114119
),
115120
)
116-
process.exit(1)
121+
process.exit(EXIT_API_ERROR)
117122
}
118123
}
119124

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)