Repository: ProjectOpenSea/opensea-js

Issue:
Include retry-after info on 429 errors
### Component

SDK

### Describe the feature you would like

It does not look like the SDK returns the retry-after header that is returned by the API on 429 errors so SDK users have no way to use a backoff mechanism and implement retries when ratelimited. Ideally the value of the retry-after header is included in the response body when the SDK throws the 429 error to enable this sort of capability for SDK users.

Hints from issue comments:
@ryanio: this is a great idea, i'm not sure if we have time to implement right now but would be open to a PR!@0xmichalis: Thanks for the prompt response @ryanio, opened https://github.com/ProjectOpenSea/opensea-js/pull/1671@github-actions: This issue has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs. Thank you for your contributions. If you believe this was a mistake, please comment.

Solution patch:
diff --git a/package-lock.json b/package-lock.json
index e583581..aefbe95 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
         "@types/chai-as-promised": "^7.1.5",
         "@types/mocha": "^10.0.0",
         "@types/node": "^22.0.0",
+        "@types/sinon": "^17.0.4",
         "@typescript-eslint/eslint-plugin": "^7.0.0",
         "@typescript-eslint/parser": "^7.0.0",
         "chai": "^4.4.1",
@@ -37,6 +38,7 @@
         "nyc": "^17.0.0",
         "prettier": "^3.0.0",
         "prettier-package-json": "^2.8.0",
+        "sinon": "^21.0.0",
         "ts-node": "^10.9.2",
         "typechain": "^8.0.0",
         "typedoc": "^0.25.0",
@@ -942,6 +944,44 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@sinonjs/commons": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+      "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/commons/node_modules/type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@sinonjs/fake-timers": {
+      "version": "13.0.5",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz",
+      "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.1"
+      }
+    },
+    "node_modules/@sinonjs/samsam": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz",
+      "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.1",
+        "lodash.get": "^4.4.2",
+        "type-detect": "^4.1.0"
+      }
+    },
     "node_modules/@tsconfig/node10": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.10.tgz",
@@ -1056,6 +1096,21 @@
       "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==",
       "dev": true
     },
+    "node_modules/@types/sinon": {
+      "version": "17.0.4",
+      "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz",
+      "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==",
+      "dev": true,
+      "dependencies": {
+        "@types/sinonjs__fake-timers": "*"
+      }
+    },
+    "node_modules/@types/sinonjs__fake-timers": {
+      "version": "8.1.5",
+      "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz",
+      "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==",
+      "dev": true
+    },
     "node_modules/@typescript-eslint/eslint-plugin": {
       "version": "7.17.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.17.0.tgz",
@@ -5027,6 +5082,13 @@
       "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==",
       "dev": true
     },
+    "node_modules/lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
+      "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
+      "dev": true
+    },
     "node_modules/lodash.merge": {
       "version": "4.6.2",
       "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6958,6 +7020,44 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/sinon": {
+      "version": "21.0.0",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz",
+      "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.1",
+        "@sinonjs/fake-timers": "^13.0.5",
+        "@sinonjs/samsam": "^8.0.1",
+        "diff": "^7.0.0",
+        "supports-color": "^7.2.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/sinon"
+      }
+    },
+    "node_modules/sinon/node_modules/diff": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
+      "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/sinon/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/slash": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
diff --git a/package.json b/package.json
index e4dcdb4..e9faedd 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
     "@types/chai-as-promised": "^7.1.5",
     "@types/mocha": "^10.0.0",
     "@types/node": "^22.0.0",
+    "@types/sinon": "^17.0.4",
     "@typescript-eslint/eslint-plugin": "^7.0.0",
     "@typescript-eslint/parser": "^7.0.0",
     "chai": "^4.4.1",
@@ -65,6 +66,7 @@
     "nyc": "^17.0.0",
     "prettier": "^3.0.0",
     "prettier-package-json": "^2.8.0",
+    "sinon": "^21.0.0",
     "ts-node": "^10.9.2",
     "typechain": "^8.0.0",
     "typedoc": "^0.25.0",
diff --git a/src/api/api.ts b/src/api/api.ts
index a43a792..a09286e 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -64,6 +64,7 @@ import {
   OpenSeaCollection,
   OpenSeaCollectionStats,
   OpenSeaPaymentToken,
+  OpenSeaRateLimitError,
   OrderSide,
 } from "../types";
 import {
@@ -755,6 +756,10 @@ export class OpenSeaAPI {
 
     const response = await req.send();
     if (!response.ok()) {
+      // Handle rate limit errors (429 Too Many Requests and 599 custom rate limit)
+      if (response.statusCode === 599 || response.statusCode === 429) {
+        throw this._createRateLimitError(response);
+      }
       // If an errors array is returned, throw with the error messages.
       const errors = response.bodyJson?.errors;
       if (errors?.length > 0) {
@@ -771,4 +776,38 @@ export class OpenSeaAPI {
     }
     return response.bodyJson;
   }
+
+  /**
+   * Parses the retry-after header from the response with robust error handling.
+   * @param response The HTTP response object from the API
+   * @returns The retry-after value in seconds, or undefined if not present or invalid
+   */
+  private _parseRetryAfter(response: ethers.FetchResponse): number | undefined {
+    const retryAfterHeader =
+      response.headers["retry-after"] || response.headers["Retry-After"];
+    if (retryAfterHeader) {
+      const parsed = parseInt(retryAfterHeader, 10);
+      return isNaN(parsed) || parsed <= 0 ? undefined : parsed;
+    }
+    return undefined;
+  }
+
+  /**
+   * Creates a rate limit error with retry-after information for backwards compatibility.
+   * @param response The HTTP response object from the API
+   * @returns An enhanced Error object with retryAfter and responseBody properties
+   */
+  private _createRateLimitError(
+    response: ethers.FetchResponse,
+  ): OpenSeaRateLimitError {
+    const retryAfter = this._parseRetryAfter(response);
+    const error = new Error(
+      `${response.statusCode} ${response.statusMessage}`,
+    ) as OpenSeaRateLimitError;
+
+    // Add retry-after information to the error object for backwards compatibility
+    error.retryAfter = retryAfter;
+    error.responseBody = response.bodyJson;
+    return error;
+  }
 }
diff --git a/src/types.ts b/src/types.ts
index 910ba8a..acd5e20 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -392,3 +392,14 @@ export interface SocialMediaAccount {
   platform: string;
   username: string;
 }
+
+/**
+ * Enhanced Error type for rate limit errors that includes retry-after information
+ * @category API Models
+ */
+export interface OpenSeaRateLimitError extends Error {
+  /** The number of seconds to wait before retrying the request */
+  retryAfter?: number;
+  /** The response body from the API */
+  responseBody?: unknown;
+}



Test patch:
diff --git a/test/api/api.spec.ts b/test/api/api.spec.ts
index 0132f41..c499a3f 100644
--- a/test/api/api.spec.ts
+++ b/test/api/api.spec.ts
@@ -1,6 +1,7 @@
 import { assert } from "chai";
 import { suite, test } from "mocha";
-import { Chain } from "../../src";
+import * as sinon from "sinon";
+import { Chain, OpenSeaRateLimitError } from "../../src";
 import { getWETHAddress } from "../../src/utils";
 import {
   BAYC_CONTRACT_ADDRESS,
@@ -42,4 +43,96 @@ suite("API", () => {
       assert.include((error as Error).message, "not found");
     }
   });
+
+  test("API handles rate limit errors with retry-after", async () => {
+    // Mock the _fetch method directly to simulate rate limit response
+    const rateLimitError = new Error(
+      "429 Too Many Requests",
+    ) as OpenSeaRateLimitError;
+    rateLimitError.retryAfter = 60;
+    rateLimitError.responseBody = { error: "Rate limited" };
+
+    // Stub the private _fetch method to throw our rate limit error
+    const fetchStub = sinon
+      .stub(mainAPI as unknown as { _fetch: () => Promise<unknown> }, "_fetch")
+      .rejects(rateLimitError);
+
+    try {
+      // This should trigger a rate limit error
+      await mainAPI.getPaymentToken(
+        "0x0000000000000000000000000000000000000000",
+      );
+      assert.fail("Expected rate limit error to be thrown");
+    } catch (error) {
+      const rateLimitError = error as OpenSeaRateLimitError;
+      assert.equal(rateLimitError.retryAfter, 60);
+      assert.deepEqual(rateLimitError.responseBody, { error: "Rate limited" });
+      assert.include(rateLimitError.message, "429 Too Many Requests");
+    } finally {
+      fetchStub.restore();
+    }
+  });
+
+  test("API handles custom 599 rate limit errors with retry-after", async () => {
+    // Mock the _fetch method directly to simulate 599 rate limit response
+    const rateLimitError = new Error(
+      "599 Network Connect Timeout Error",
+    ) as OpenSeaRateLimitError;
+    rateLimitError.retryAfter = 30;
+    rateLimitError.responseBody = { message: "Custom rate limit" };
+
+    // Stub the private _fetch method to throw our rate limit error
+    const fetchStub = sinon
+      .stub(mainAPI as unknown as { _fetch: () => Promise<unknown> }, "_fetch")
+      .rejects(rateLimitError);
+
+    try {
+      // This should trigger a rate limit error
+      await mainAPI.getPaymentToken(
+        "0x0000000000000000000000000000000000000000",
+      );
+      assert.fail("Expected rate limit error to be thrown");
+    } catch (error) {
+      const rateLimitError = error as OpenSeaRateLimitError;
+      assert.equal(rateLimitError.retryAfter, 30);
+      assert.deepEqual(rateLimitError.responseBody, {
+        message: "Custom rate limit",
+      });
+      assert.include(
+        rateLimitError.message,
+        "599 Network Connect Timeout Error",
+      );
+    } finally {
+      fetchStub.restore();
+    }
+  });
+
+  test("API handles invalid retry-after header gracefully", async () => {
+    // Test the robust header parsing by simulating an invalid retry-after header
+    const rateLimitError = new Error(
+      "429 Too Many Requests",
+    ) as OpenSeaRateLimitError;
+    rateLimitError.retryAfter = undefined; // Simulate invalid header parsing
+    rateLimitError.responseBody = { error: "Rate limited" };
+
+    // Stub the private _fetch method to throw our rate limit error
+    const fetchStub = sinon
+      .stub(mainAPI as unknown as { _fetch: () => Promise<unknown> }, "_fetch")
+      .rejects(rateLimitError);
+
+    try {
+      // This should trigger a rate limit error
+      await mainAPI.getPaymentToken(
+        "0x0000000000000000000000000000000000000000",
+      );
+      assert.fail("Expected rate limit error to be thrown");
+    } catch (error) {
+      const rateLimitError = error as OpenSeaRateLimitError;
+      assert.equal(rateLimitError.retryAfter, undefined);
+      assert.deepEqual(rateLimitError.responseBody, { error: "Rate limited" });
+      assert.include(rateLimitError.message, "429 Too Many Requests");
+    } finally {
+      fetchStub.restore();
+    }
+  });
 });