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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
4 changes: 3 additions & 1 deletion src/scrapebadger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ async def main():
from scrapebadger._internal.exceptions import (
AuthenticationError,
InsufficientCreditsError,
IPNotAllowedError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
Expand Down Expand Up @@ -1187,7 +1188,7 @@ async def main():
ZestimateHistoryPoint as ZillowZestimateHistoryPoint,
)

__version__ = "0.52.0"
__version__ = "0.53.0"

__all__ = [
# TikTok core models
Expand Down Expand Up @@ -1329,6 +1330,7 @@ async def main():
"GeminiSearchResult",
# Google Scraper
"GoogleClient",
"IPNotAllowedError",
# Immobiliare
"ImmobiliareAgency",
"ImmobiliareAgencyAgent",
Expand Down
2 changes: 2 additions & 0 deletions src/scrapebadger/_internal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from scrapebadger._internal.exceptions import (
AuthenticationError,
InsufficientCreditsError,
IPNotAllowedError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
Expand All @@ -18,6 +19,7 @@
"AuthenticationError",
"BaseClient",
"ClientConfig",
"IPNotAllowedError",
"InsufficientCreditsError",
"NotFoundError",
"PaginatedResponse",
Expand Down
4 changes: 4 additions & 0 deletions src/scrapebadger/_internal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from scrapebadger._internal.exceptions import (
AuthenticationError,
InsufficientCreditsError,
IPNotAllowedError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
Expand Down Expand Up @@ -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)

Expand Down
19 changes: 19 additions & 0 deletions src/scrapebadger/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 24 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading