Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 9 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ program
"Comma-separated list of fields to include in output",
)
.option("--max-lines <lines>", "Truncate output after N lines")
.option("--max-retries <n>", "Max retries on 429/5xx (0 to disable)", "3")
.option("--no-retry", "Disable request retries")

function getClient(): OpenSeaClient {
const opts = program.opts<{
Expand All @@ -56,6 +58,8 @@ function getClient(): OpenSeaClient {
baseUrl?: string
timeout: string
verbose?: boolean
maxRetries: string
retry: boolean
}>()

const apiKey = opts.apiKey ?? process.env.OPENSEA_API_KEY
Expand All @@ -66,12 +70,17 @@ function getClient(): OpenSeaClient {
process.exit(EXIT_AUTH_ERROR)
}

const maxRetries = opts.retry
? parseIntOption(opts.maxRetries, "--max-retries")
: 0

return new OpenSeaClient({
apiKey,
chain: opts.chain,
baseUrl: opts.baseUrl,
timeout: parseIntOption(opts.timeout, "--timeout"),
verbose: opts.verbose,
maxRetries,
})
}

Expand Down
121 changes: 92 additions & 29 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,44 @@ declare const __VERSION__: string
const DEFAULT_BASE_URL = "https://api.opensea.io"
const DEFAULT_TIMEOUT_MS = 30_000
const USER_AGENT = `opensea-cli/${__VERSION__}`
const DEFAULT_MAX_RETRIES = 0
const DEFAULT_RETRY_BASE_DELAY_MS = 1_000

function isRetryableStatus(status: number, method: string): boolean {
if (status === 429) return true
return status >= 500 && method === "GET"
}

function parseRetryAfter(header: string | null): number | undefined {
if (!header) return undefined
const seconds = Number(header)
if (!Number.isNaN(seconds)) return seconds * 1000
const date = Date.parse(header)
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
return undefined
}

function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

export class OpenSeaClient {
private apiKey: string
private baseUrl: string
private defaultChain: string
private timeoutMs: number
private verbose: boolean
private maxRetries: number
private retryBaseDelay: number

constructor(config: OpenSeaClientConfig) {
this.apiKey = config.apiKey
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL
this.defaultChain = config.chain ?? "ethereum"
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS
this.verbose = config.verbose ?? false
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES
this.retryBaseDelay = config.retryBaseDelay ?? DEFAULT_RETRY_BASE_DELAY_MS
}

private get defaultHeaders(): Record<string, string> {
Expand All @@ -44,20 +68,14 @@ export class OpenSeaClient {
console.error(`[verbose] GET ${url.toString()}`)
}

const response = await fetch(url.toString(), {
method: "GET",
headers: this.defaultHeaders,
signal: AbortSignal.timeout(this.timeoutMs),
})

if (this.verbose) {
console.error(`[verbose] ${response.status} ${response.statusText}`)
}

if (!response.ok) {
const body = await response.text()
throw new OpenSeaAPIError(response.status, body, path)
}
const response = await this.fetchWithRetry(
url.toString(),
{
method: "GET",
headers: this.defaultHeaders,
},
path,
)

return response.json() as Promise<T>
}
Expand Down Expand Up @@ -87,21 +105,15 @@ export class OpenSeaClient {
console.error(`[verbose] POST ${url.toString()}`)
}

const response = await fetch(url.toString(), {
method: "POST",
headers,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(this.timeoutMs),
})

if (this.verbose) {
console.error(`[verbose] ${response.status} ${response.statusText}`)
}

if (!response.ok) {
const text = await response.text()
throw new OpenSeaAPIError(response.status, text, path)
}
const response = await this.fetchWithRetry(
url.toString(),
{
method: "POST",
headers,
body: body ? JSON.stringify(body) : undefined,
},
path,
)

return response.json() as Promise<T>
}
Expand All @@ -114,6 +126,57 @@ export class OpenSeaClient {
if (this.apiKey.length < 8) return "***"
return `${this.apiKey.slice(0, 4)}...`
}

private async fetchWithRetry(
url: string,
init: RequestInit,
path: string,
): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(this.timeoutMs),
})

if (this.verbose) {
console.error(`[verbose] ${response.status} ${response.statusText}`)
}

if (response.ok) {
return response
}

const method = init.method ?? "GET"
if (
attempt < this.maxRetries &&
isRetryableStatus(response.status, method)
) {
const retryAfterMs = parseRetryAfter(
response.headers.get("Retry-After"),
)
const backoffMs = this.retryBaseDelay * 2 ** attempt
const jitterMs = Math.random() * this.retryBaseDelay
const delayMs = Math.max(retryAfterMs ?? 0, backoffMs) + jitterMs

if (this.verbose) {
console.error(
`[verbose] Retry ${attempt + 1}/${this.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`,
)
}

try {
await response.body?.cancel()
} catch {
// Stream may already be disturbed
}
await sleep(delayMs)
continue
}

const text = await response.text()
throw new OpenSeaAPIError(response.status, text, path)
}
}
}

export class OpenSeaAPIError extends Error {
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface OpenSeaClientConfig {
chain?: string
timeout?: number
verbose?: boolean
maxRetries?: number
retryBaseDelay?: number
}

export interface CommandOptions {
Expand Down
1 change: 1 addition & 0 deletions test/cli-api-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({
searchCommand: () => new Command("search"),
swapsCommand: () => new Command("swaps"),
tokensCommand: () => new Command("tokens"),
healthCommand: () => new Command("health"),
}))

const exitSpy = vi
Expand Down
1 change: 1 addition & 0 deletions test/cli-network-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock("../src/commands/index.js", () => ({
searchCommand: () => new Command("search"),
swapsCommand: () => new Command("swaps"),
tokensCommand: () => new Command("tokens"),
healthCommand: () => new Command("health"),
}))

const exitSpy = vi
Expand Down
1 change: 1 addition & 0 deletions test/cli-rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({
searchCommand: () => new Command("search"),
swapsCommand: () => new Command("swaps"),
tokensCommand: () => new Command("tokens"),
healthCommand: () => new Command("health"),
}))

const exitSpy = vi
Expand Down
Loading