-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add opensea health diagnostic command (DIS-144) #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9b5167a
feat: add opensea health diagnostic command (DIS-144)
devin-ai-integration[bot] 5a7d6c2
fix: address code review feedback on health command (DIS-144)
devin-ai-integration[bot] b3c0300
fix: validate API key authentication in health check
ckorhonen 0a4908d
fix: biome formatting and stale comment in health check
devin-ai-integration[bot] 03ee4ae
fix: guard short API keys, add 429 rate-limit handling with exit code 3
devin-ai-integration[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { Command } from "commander" | ||
| import type { OpenSeaClient } from "../client.js" | ||
| import { checkHealth } from "../health.js" | ||
| import type { OutputFormat } from "../output.js" | ||
| import { formatOutput } from "../output.js" | ||
|
|
||
| export function healthCommand( | ||
| getClient: () => OpenSeaClient, | ||
| getFormat: () => OutputFormat, | ||
| ): Command { | ||
| const cmd = new Command("health") | ||
| .description("Check API connectivity and authentication") | ||
| .action(async () => { | ||
| const client = getClient() | ||
| const result = await checkHealth(client) | ||
| console.log(formatOutput(result, getFormat())) | ||
| if (result.status === "error") { | ||
| process.exit(result.rate_limited ? 3 : 1) | ||
ckorhonen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
ckorhonen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }) | ||
|
|
||
| return cmd | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { OpenSeaAPIError, type OpenSeaClient } from "./client.js" | ||
| import type { HealthResult } from "./types/index.js" | ||
|
|
||
| export async function checkHealth( | ||
| client: OpenSeaClient, | ||
| ): Promise<HealthResult> { | ||
| const keyPrefix = client.getApiKeyPrefix() | ||
|
|
||
| // Step 1: Check basic connectivity with a public endpoint | ||
| try { | ||
| await client.get("/api/v2/collections", { limit: 1 }) | ||
| } catch (error) { | ||
| let message: string | ||
| if (error instanceof OpenSeaAPIError) { | ||
| message = | ||
| error.statusCode === 429 | ||
| ? "Rate limited: too many requests" | ||
| : `API error (${error.statusCode}): ${error.responseBody}` | ||
| } else { | ||
| message = `Network error: ${(error as Error).message}` | ||
| } | ||
| return { | ||
| status: "error", | ||
| key_prefix: keyPrefix, | ||
| authenticated: false, | ||
| rate_limited: | ||
| error instanceof OpenSeaAPIError && error.statusCode === 429, | ||
| message, | ||
| } | ||
| } | ||
|
|
||
| // Step 2: Validate authentication with an endpoint that requires a valid API key | ||
| try { | ||
| await client.get("/api/v2/listings/collection/boredapeyachtclub/all", { | ||
| limit: 1, | ||
| }) | ||
| return { | ||
| status: "ok", | ||
| key_prefix: keyPrefix, | ||
| authenticated: true, | ||
| rate_limited: false, | ||
| message: "Connectivity and authentication are working", | ||
| } | ||
| } catch (error) { | ||
| if (error instanceof OpenSeaAPIError) { | ||
| if (error.statusCode === 429) { | ||
| return { | ||
| status: "error", | ||
| key_prefix: keyPrefix, | ||
| authenticated: false, | ||
| rate_limited: true, | ||
| message: "Rate limited: too many requests", | ||
| } | ||
| } | ||
| if (error.statusCode === 401 || error.statusCode === 403) { | ||
| return { | ||
| status: "error", | ||
| key_prefix: keyPrefix, | ||
| authenticated: false, | ||
| rate_limited: false, | ||
| message: `Authentication failed (${error.statusCode}): invalid API key`, | ||
| } | ||
| } | ||
| } | ||
| // Non-auth error on listings endpoint — connectivity works but auth is unverified | ||
| return { | ||
| status: "ok", | ||
| key_prefix: keyPrefix, | ||
| authenticated: false, | ||
| rate_limited: false, | ||
| message: | ||
| "Connectivity is working but authentication could not be verified", | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" | ||
| import { OpenSeaAPIError } from "../../src/client.js" | ||
| import { healthCommand } from "../../src/commands/health.js" | ||
| import { type CommandTestContext, createCommandTestContext } from "../mocks.js" | ||
|
|
||
| describe("healthCommand", () => { | ||
| let ctx: CommandTestContext | ||
|
|
||
| beforeEach(() => { | ||
| ctx = createCommandTestContext() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks() | ||
| }) | ||
|
|
||
| it("creates command with correct name", () => { | ||
| const cmd = healthCommand(ctx.getClient, ctx.getFormat) | ||
| expect(cmd.name()).toBe("health") | ||
| }) | ||
|
|
||
| it("outputs ok status when both connectivity and auth succeed", async () => { | ||
| ctx.mockClient.get.mockResolvedValue({}) | ||
|
|
||
| const cmd = healthCommand(ctx.getClient, ctx.getFormat) | ||
| await cmd.parseAsync([], { from: "user" }) | ||
|
|
||
| expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/collections", { | ||
| limit: 1, | ||
| }) | ||
| expect(ctx.mockClient.get).toHaveBeenCalledWith( | ||
| "/api/v2/listings/collection/boredapeyachtclub/all", | ||
| { | ||
| limit: 1, | ||
| }, | ||
| ) | ||
| const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) | ||
| expect(output.status).toBe("ok") | ||
| expect(output.key_prefix).toBe("test...") | ||
| expect(output.authenticated).toBe(true) | ||
| expect(output.message).toBe("Connectivity and authentication are working") | ||
| }) | ||
|
|
||
| it("outputs error status when connectivity fails", async () => { | ||
| ctx.mockClient.get.mockRejectedValue( | ||
| new OpenSeaAPIError(500, "Internal Server Error", "/api/v2/collections"), | ||
| ) | ||
|
|
||
| const mockExit = vi | ||
| .spyOn(process, "exit") | ||
| .mockImplementation(() => undefined as never) | ||
|
|
||
| const cmd = healthCommand(ctx.getClient, ctx.getFormat) | ||
| await cmd.parseAsync([], { from: "user" }) | ||
|
|
||
| const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) | ||
| expect(output.status).toBe("error") | ||
| expect(output.authenticated).toBe(false) | ||
| expect(output.message).toContain("API error (500)") | ||
| expect(mockExit).toHaveBeenCalledWith(1) | ||
| }) | ||
|
|
||
| it("outputs error status when auth fails (401)", async () => { | ||
| ctx.mockClient.get | ||
| .mockResolvedValueOnce({}) // connectivity ok | ||
| .mockRejectedValueOnce( | ||
| new OpenSeaAPIError( | ||
| 401, | ||
| "Unauthorized", | ||
| "/api/v2/listings/collection/boredapeyachtclub/all", | ||
| ), | ||
| ) | ||
|
|
||
| const mockExit = vi | ||
| .spyOn(process, "exit") | ||
| .mockImplementation(() => undefined as never) | ||
|
|
||
| const cmd = healthCommand(ctx.getClient, ctx.getFormat) | ||
| await cmd.parseAsync([], { from: "user" }) | ||
|
|
||
| const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) | ||
| expect(output.status).toBe("error") | ||
| expect(output.authenticated).toBe(false) | ||
| expect(output.message).toContain("Authentication failed (401)") | ||
| expect(mockExit).toHaveBeenCalledWith(1) | ||
| }) | ||
|
|
||
| it("outputs error status on network errors", async () => { | ||
| ctx.mockClient.get.mockRejectedValue(new TypeError("fetch failed")) | ||
|
|
||
| const mockExit = vi | ||
| .spyOn(process, "exit") | ||
| .mockImplementation(() => undefined as never) | ||
|
|
||
| const cmd = healthCommand(ctx.getClient, ctx.getFormat) | ||
| await cmd.parseAsync([], { from: "user" }) | ||
|
|
||
| const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) | ||
| expect(output.status).toBe("error") | ||
| expect(output.message).toContain("Network error: fetch failed") | ||
| expect(mockExit).toHaveBeenCalledWith(1) | ||
| }) | ||
|
|
||
| it("reports ok with unverified auth when listings endpoint has non-auth error", async () => { | ||
| ctx.mockClient.get | ||
| .mockResolvedValueOnce({}) // connectivity ok | ||
| .mockRejectedValueOnce( | ||
| new OpenSeaAPIError( | ||
| 500, | ||
| "Server Error", | ||
| "/api/v2/listings/collection/boredapeyachtclub/all", | ||
| ), | ||
| ) | ||
|
|
||
| const cmd = healthCommand(ctx.getClient, ctx.getFormat) | ||
| await cmd.parseAsync([], { from: "user" }) | ||
|
|
||
| const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) | ||
| expect(output.status).toBe("ok") | ||
| expect(output.authenticated).toBe(false) | ||
| expect(output.message).toContain("could not be verified") | ||
| }) | ||
|
|
||
| it("exits with code 3 on rate limit (429)", async () => { | ||
| ctx.mockClient.get.mockRejectedValue( | ||
| new OpenSeaAPIError(429, "Too Many Requests", "/api/v2/collections"), | ||
| ) | ||
|
|
||
| const mockExit = vi | ||
| .spyOn(process, "exit") | ||
| .mockImplementation(() => undefined as never) | ||
|
|
||
| const cmd = healthCommand(ctx.getClient, ctx.getFormat) | ||
| await cmd.parseAsync([], { from: "user" }) | ||
|
|
||
| const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) | ||
| expect(output.status).toBe("error") | ||
| expect(output.rate_limited).toBe(true) | ||
| expect(output.message).toContain("Rate limited") | ||
| expect(mockExit).toHaveBeenCalledWith(3) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.