diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c8e72a..1d1292e 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 raises `IPNotAllowedError`, carrying `client_ip` — the address ScrapeBadger saw. It subclasses `PermissionDeniedError`, so existing handlers still catch it. + ## 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 raises `PermissionDeniedError` for it, carrying `required_scope` and `allowed_scopes`. It subclasses `ScrapeBadgerError`, so existing `except ScrapeBadgerError` handlers still catch it. diff --git a/pyproject.toml b/pyproject.toml index df091f3..2b631be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.52.0" +version = "0.53.0" description = "Official Python SDK for ScrapeBadger - Async web scraping APIs for Twitter and more" readme = "README.md" license = { text = "MIT" } diff --git a/src/scrapebadger/__init__.py b/src/scrapebadger/__init__.py index 6a44abb..6128109 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -33,6 +33,7 @@ async def main(): from scrapebadger._internal.exceptions import ( AuthenticationError, InsufficientCreditsError, + IPNotAllowedError, NotFoundError, PermissionDeniedError, RateLimitError, @@ -1187,7 +1188,7 @@ async def main(): ZestimateHistoryPoint as ZillowZestimateHistoryPoint, ) -__version__ = "0.52.0" +__version__ = "0.53.0" __all__ = [ # TikTok core models @@ -1329,6 +1330,7 @@ async def main(): "GeminiSearchResult", # Google Scraper "GoogleClient", + "IPNotAllowedError", # Immobiliare "ImmobiliareAgency", "ImmobiliareAgencyAgent", diff --git a/src/scrapebadger/_internal/__init__.py b/src/scrapebadger/_internal/__init__.py index 2332962..62c569e 100644 --- a/src/scrapebadger/_internal/__init__.py +++ b/src/scrapebadger/_internal/__init__.py @@ -5,6 +5,7 @@ from scrapebadger._internal.exceptions import ( AuthenticationError, InsufficientCreditsError, + IPNotAllowedError, NotFoundError, PermissionDeniedError, RateLimitError, @@ -18,6 +19,7 @@ "AuthenticationError", "BaseClient", "ClientConfig", + "IPNotAllowedError", "InsufficientCreditsError", "NotFoundError", "PaginatedResponse", diff --git a/src/scrapebadger/_internal/client.py b/src/scrapebadger/_internal/client.py index fefd1f7..86759f3 100644 --- a/src/scrapebadger/_internal/client.py +++ b/src/scrapebadger/_internal/client.py @@ -15,6 +15,7 @@ from scrapebadger._internal.exceptions import ( AuthenticationError, InsufficientCreditsError, + IPNotAllowedError, NotFoundError, PermissionDeniedError, RateLimitError, @@ -176,6 +177,9 @@ def _handle_error_response( if status_code == 402: raise InsufficientCreditsError(message, status_code, data) + if status_code == 403 and data.get("error") == "ip_not_allowed": + raise IPNotAllowedError(message, status_code, data) + if status_code == 403 and data.get("error") == "insufficient_scope": raise PermissionDeniedError(message, status_code, data) diff --git a/src/scrapebadger/_internal/exceptions.py b/src/scrapebadger/_internal/exceptions.py index 7aa10c4..0254e60 100644 --- a/src/scrapebadger/_internal/exceptions.py +++ b/src/scrapebadger/_internal/exceptions.py @@ -108,6 +108,25 @@ def __init__( self.allowed_scopes: list[str] = list(self.response_data.get("allowed_scopes") or []) +class IPNotAllowedError(PermissionDeniedError): + """Raised when the API key is restricted to IP addresses and this request + came from another one (403, ``error: "ip_not_allowed"``). Not charged. + + Attributes: + client_ip: The address the request came from, as ScrapeBadger saw it + (None when it was relayed, e.g. through the hosted MCP server). + """ + + def __init__( + self, + message: str = "This API key may not be used from this IP address", + status_code: int = 403, + response_data: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, status_code, response_data) + self.client_ip: str | None = self.response_data.get("client_ip") + + class RateLimitError(ScrapeBadgerError): """Raised when rate limit is exceeded. diff --git a/tests/test_client.py b/tests/test_client.py index 1429c6d..c00eeaa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -165,3 +165,27 @@ def handler(request: httpx.Request) -> httpx.Response: assert exc.value.allowed_scopes == ["google", "amazon"] assert "Twitter / X" in str(exc.value) assert isinstance(exc.value, ScrapeBadgerError) + + +class TestIPNotAllowed: + async def test_ip_not_allowed_raises_ip_error(self, api_key: str) -> None: + import httpx + + from scrapebadger import IPNotAllowedError, PermissionDeniedError + from scrapebadger._internal.client import BaseClient + from scrapebadger._internal.config import ClientConfig + + body = { + "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", + } + client = BaseClient(ClientConfig(api_key=api_key)) + client._client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda r: httpx.Response(403, json=body)), + base_url="https://api.test", + ) + with pytest.raises(IPNotAllowedError) as exc: + await client.get("/v1/google/search") + assert exc.value.client_ip == "203.0.113.9" + assert isinstance(exc.value, PermissionDeniedError) diff --git a/uv.lock b/uv.lock index 2851d25..c710ad5 100644 --- a/uv.lock +++ b/uv.lock @@ -930,7 +930,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.52.0" +version = "0.53.0" source = { editable = "." } dependencies = [ { name = "httpx" },