From 661bcadfbd22ba0485cd693292cd5f15a37065b8 Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:12:46 +0300 Subject: [PATCH] feat: IPNotAllowedError for 403 ip_not_allowed (0.53.0) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- src/index.ts | 1 + src/internal/client.ts | 5 +++++ src/internal/exceptions.ts | 16 ++++++++++++++++ tests/exceptions.test.ts | 32 ++++++++++++++++++++++++++++++++ 7 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6c4d3..2ce8843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.53.0 — 2026-10-05 + +- **`IPNotAllowedError` for keys restricted to IP addresses.** API keys can now be limited to IP addresses and CIDR ranges in the dashboard; a request from anywhere else answers `403` with `error: "ip_not_allowed"` (not charged). The SDK throws `IPNotAllowedError` with `clientIp` — the address ScrapeBadger saw. It extends `PermissionDeniedError` (and so `AuthenticationError`), so existing handlers still match. + ## 0.52.0 — 2026-10-05 - **`PermissionDeniedError` for keys restricted to specific APIs.** API keys can now be limited to chosen APIs in the dashboard; calling any other API answers `403` with `error: "insufficient_scope"` (and charges nothing). The SDK throws `PermissionDeniedError` for it, with `requiredScope` and `allowedScopes`. It extends `AuthenticationError` — what every non-restriction `403` threw before — so existing `catch` handlers still match. diff --git a/package-lock.json b/package-lock.json index 00b86b3..0b2c6fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrapebadger", - "version": "0.52.0", + "version": "0.53.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrapebadger", - "version": "0.52.0", + "version": "0.53.0", "license": "MIT", "dependencies": { "ws": "^8.18.0" diff --git a/package.json b/package.json index 74125b4..1576d68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrapebadger", - "version": "0.52.0", + "version": "0.53.0", "description": "Official Node.js SDK for ScrapeBadger - Async web scraping APIs for Twitter, Google, Vinted, Reddit, and more", "type": "module", "main": "./dist/index.js", diff --git a/src/index.ts b/src/index.ts index 2cd93b1..6ff0b77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ export { InsufficientCreditsError, AccountRestrictedError, PermissionDeniedError, + IPNotAllowedError, ConflictError, WebSocketStreamError, } from "./internal/exceptions.js"; diff --git a/src/internal/client.ts b/src/internal/client.ts index 705437d..adfc1b7 100644 --- a/src/internal/client.ts +++ b/src/internal/client.ts @@ -14,6 +14,7 @@ import { InsufficientCreditsError, AccountRestrictedError, PermissionDeniedError, + IPNotAllowedError, ConflictError, ScrapeBadgerError, } from "./exceptions.js"; @@ -56,6 +57,7 @@ interface ErrorResponse { error?: string; required_scope?: string; allowed_scopes?: string[]; + client_ip?: string | null; } /** @@ -345,6 +347,9 @@ export class BaseClient { throw new InsufficientCreditsError(message, errorData.credits_balance); case 403: + if (errorData.error === "ip_not_allowed") { + throw new IPNotAllowedError(message, errorData.client_ip ?? null); + } if (errorData.error === "insufficient_scope") { throw new PermissionDeniedError( message, diff --git a/src/internal/exceptions.ts b/src/internal/exceptions.ts index afb05ae..ad51b2e 100644 --- a/src/internal/exceptions.ts +++ b/src/internal/exceptions.ts @@ -50,6 +50,22 @@ export class PermissionDeniedError extends AuthenticationError { } } +/** + * Raised when the API key is restricted to IP addresses and this request came + * from another one (HTTP 403, `error: "ip_not_allowed"`). Not charged. + */ +export class IPNotAllowedError extends PermissionDeniedError { + /** The address ScrapeBadger saw (null when relayed, e.g. via the hosted MCP server) */ + readonly clientIp: string | null; + + constructor(message = "This API key may not be used from this IP address.", clientIp: string | null = null) { + super(message); + this.name = "IPNotAllowedError"; + this.clientIp = clientIp; + Object.setPrototypeOf(this, IPNotAllowedError.prototype); + } +} + /** * Raised when rate limit is exceeded. */ diff --git a/tests/exceptions.test.ts b/tests/exceptions.test.ts index 0c0ec89..259eb54 100644 --- a/tests/exceptions.test.ts +++ b/tests/exceptions.test.ts @@ -206,4 +206,36 @@ describe("Exceptions", () => { } }); }); + + describe("IPNotAllowedError", () => { + it("is thrown on 403 ip_not_allowed with the client IP", async () => { + const { BaseClient } = await import("../src/internal/client.js"); + const { resolveConfig } = await import("../src/internal/config.js"); + const { IPNotAllowedError, PermissionDeniedError } = await import("../src/internal/exceptions.js"); + const client = new BaseClient(resolveConfig({ apiKey: "test-key", maxRetries: 0 })); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + detail: "This request came from 203.0.113.9, which is not on its allowlist.", + error: "ip_not_allowed", + client_ip: "203.0.113.9", + }), + { status: 403, headers: { "Content-Type": "application/json" } } + ) + ) + ); + try { + await client.request("/v1/google/search"); + expect.fail("Should have thrown IPNotAllowedError"); + } catch (err) { + expect(err).toBeInstanceOf(IPNotAllowedError); + expect(err).toBeInstanceOf(PermissionDeniedError); + expect((err as InstanceType).clientIp).toBe("203.0.113.9"); + } finally { + vi.unstubAllGlobals(); + } + }); + }); });