From 95c718c99b71edf9a32f14b274b88a17499cf559 Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:50:32 +0300 Subject: [PATCH] feat: PermissionDeniedError for 403 insufficient_scope (0.52.0) API keys can now be restricted to specific APIs; a call outside them answers 403 error=insufficient_scope with required_scope/allowed_scopes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++ pyproject.toml | 2 +- src/scrapebadger/__init__.py | 4 ++- src/scrapebadger/_internal/__init__.py | 2 ++ src/scrapebadger/_internal/client.py | 6 +++++ src/scrapebadger/_internal/exceptions.py | 31 +++++++++++++++++++++ tests/test_client.py | 34 ++++++++++++++++++++++++ uv.lock | 2 +- 8 files changed, 82 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa23e69..1c8e72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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. + ## 0.49.0 — 2026-09-19 - **Vinted image search results carry `similarity_score`.** Visual similarity to the query image on a 0–1 scale, where the query image's own listing scores `1.0`. Populated only by `search_by_image`, and only on the calls where Vinted returns a ranking; `None` otherwise. A `None` is not a weak match — the result set is identical either way — so treat it as "no score available", not as a low score. diff --git a/pyproject.toml b/pyproject.toml index 93ca2dc..df091f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.51.1" +version = "0.52.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 2e28c39..6a44abb 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -34,6 +34,7 @@ async def main(): AuthenticationError, InsufficientCreditsError, NotFoundError, + PermissionDeniedError, RateLimitError, ScrapeBadgerError, ServerError, @@ -1186,7 +1187,7 @@ async def main(): ZestimateHistoryPoint as ZillowZestimateHistoryPoint, ) -__version__ = "0.51.1" +__version__ = "0.52.0" __all__ = [ # TikTok core models @@ -1424,6 +1425,7 @@ async def main(): "OffersResponse", # Pagination "PaginatedResponse", + "PermissionDeniedError", # Reddit response envelopes "PostCommentsResponse", "PostDetailResponse", diff --git a/src/scrapebadger/_internal/__init__.py b/src/scrapebadger/_internal/__init__.py index d09a507..2332962 100644 --- a/src/scrapebadger/_internal/__init__.py +++ b/src/scrapebadger/_internal/__init__.py @@ -6,6 +6,7 @@ AuthenticationError, InsufficientCreditsError, NotFoundError, + PermissionDeniedError, RateLimitError, ScrapeBadgerError, ServerError, @@ -20,6 +21,7 @@ "InsufficientCreditsError", "NotFoundError", "PaginatedResponse", + "PermissionDeniedError", "RateLimitError", "ScrapeBadgerError", "ServerError", diff --git a/src/scrapebadger/_internal/client.py b/src/scrapebadger/_internal/client.py index 7c7958b..fefd1f7 100644 --- a/src/scrapebadger/_internal/client.py +++ b/src/scrapebadger/_internal/client.py @@ -16,6 +16,7 @@ AuthenticationError, InsufficientCreditsError, NotFoundError, + PermissionDeniedError, RateLimitError, ScrapeBadgerError, ServerError, @@ -151,6 +152,8 @@ def _handle_error_response( Raises: AuthenticationError: For 401 responses. InsufficientCreditsError: For 402 responses. + PermissionDeniedError: For 403 responses from a key restricted + away from the API it called. NotFoundError: For 404 responses. ValidationError: For 422 responses. RateLimitError: For 429 responses. @@ -173,6 +176,9 @@ def _handle_error_response( if status_code == 402: raise InsufficientCreditsError(message, status_code, data) + if status_code == 403 and data.get("error") == "insufficient_scope": + raise PermissionDeniedError(message, status_code, data) + if status_code == 404: raise NotFoundError(message, status_code, data) diff --git a/src/scrapebadger/_internal/exceptions.py b/src/scrapebadger/_internal/exceptions.py index 9667f25..7aa10c4 100644 --- a/src/scrapebadger/_internal/exceptions.py +++ b/src/scrapebadger/_internal/exceptions.py @@ -77,6 +77,37 @@ def __init__( super().__init__(message, status_code, response_data) +class PermissionDeniedError(ScrapeBadgerError): + """Raised when the API key is not permitted to call this API (403). + + API keys can be restricted to specific APIs in the dashboard. Calling + any other API returns ``403`` with ``error: "insufficient_scope"``; + no credits are charged. + + Attributes: + required_scope: The API the request needed, e.g. ``"twitter"``. + allowed_scopes: The APIs this key may call. + + Example: + ```python + try: + await client.twitter.users.get_by_username("elonmusk") + except PermissionDeniedError as e: + print(f"Key lacks {e.required_scope}; allowed: {e.allowed_scopes}") + ``` + """ + + def __init__( + self, + message: str = "This API key is not permitted to call this API", + status_code: int = 403, + response_data: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, status_code, response_data) + self.required_scope: str | None = self.response_data.get("required_scope") + self.allowed_scopes: list[str] = list(self.response_data.get("allowed_scopes") or []) + + class RateLimitError(ScrapeBadgerError): """Raised when rate limit is exceeded. diff --git a/tests/test_client.py b/tests/test_client.py index b2f35d0..1429c6d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -131,3 +131,37 @@ def handler(request: httpx.Request) -> httpx.Response: with pytest.raises(ScrapeBadgerError): await client.post_raw("/v1/web/scrape", json={"url": "x"}) + + +class TestPermissionDenied: + """403 insufficient_scope from a key restricted to other APIs.""" + + async def test_insufficient_scope_raises_permission_denied(self, api_key: str) -> None: + import httpx + + from scrapebadger import PermissionDeniedError, ScrapeBadgerError + from scrapebadger._internal.client import BaseClient + from scrapebadger._internal.config import ClientConfig + + body = { + "detail": "This API key does not have permission to use the Twitter / X API.", + "error": "insufficient_scope", + "required_scope": "twitter", + "allowed_scopes": ["google", "amazon"], + } + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(403, json=body) + + client = BaseClient(ClientConfig(api_key=api_key)) + client._client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), base_url="https://api.test" + ) + + with pytest.raises(PermissionDeniedError) as exc: + await client.get("/v1/twitter/users/x/by_username") + assert exc.value.status_code == 403 + assert exc.value.required_scope == "twitter" + assert exc.value.allowed_scopes == ["google", "amazon"] + assert "Twitter / X" in str(exc.value) + assert isinstance(exc.value, ScrapeBadgerError) diff --git a/uv.lock b/uv.lock index 6a4fd83..2851d25 100644 --- a/uv.lock +++ b/uv.lock @@ -930,7 +930,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.51.1" +version = "0.52.0" source = { editable = "." } dependencies = [ { name = "httpx" },