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.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.
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.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" }
Expand Down
4 changes: 3 additions & 1 deletion src/scrapebadger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async def main():
AuthenticationError,
InsufficientCreditsError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
ScrapeBadgerError,
ServerError,
Expand Down Expand Up @@ -1186,7 +1187,7 @@ async def main():
ZestimateHistoryPoint as ZillowZestimateHistoryPoint,
)

__version__ = "0.51.1"
__version__ = "0.52.0"

__all__ = [
# TikTok core models
Expand Down Expand Up @@ -1424,6 +1425,7 @@ async def main():
"OffersResponse",
# Pagination
"PaginatedResponse",
"PermissionDeniedError",
# Reddit response envelopes
"PostCommentsResponse",
"PostDetailResponse",
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 @@ -6,6 +6,7 @@
AuthenticationError,
InsufficientCreditsError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
ScrapeBadgerError,
ServerError,
Expand All @@ -20,6 +21,7 @@
"InsufficientCreditsError",
"NotFoundError",
"PaginatedResponse",
"PermissionDeniedError",
"RateLimitError",
"ScrapeBadgerError",
"ServerError",
Expand Down
6 changes: 6 additions & 0 deletions src/scrapebadger/_internal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AuthenticationError,
InsufficientCreditsError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
ScrapeBadgerError,
ServerError,
Expand Down Expand Up @@ -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.
Expand All @@ -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)

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

Expand Down
34 changes: 34 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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