brightdata-hotdata-bridge 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brightdata_hotdata_bridge/__about__.py +3 -0
- brightdata_hotdata_bridge/__init__.py +105 -0
- brightdata_hotdata_bridge/brightdata.py +371 -0
- brightdata_hotdata_bridge/catalog.py +254 -0
- brightdata_hotdata_bridge/cli.py +471 -0
- brightdata_hotdata_bridge/config.py +173 -0
- brightdata_hotdata_bridge/errors.py +184 -0
- brightdata_hotdata_bridge/hotdata_client.py +511 -0
- brightdata_hotdata_bridge/inputs.py +250 -0
- brightdata_hotdata_bridge/logging_setup.py +104 -0
- brightdata_hotdata_bridge/models.py +286 -0
- brightdata_hotdata_bridge/pipeline.py +640 -0
- brightdata_hotdata_bridge/py.typed +0 -0
- brightdata_hotdata_bridge/state.py +194 -0
- brightdata_hotdata_bridge/validation.py +276 -0
- brightdata_hotdata_bridge-0.1.1.dist-info/METADATA +258 -0
- brightdata_hotdata_bridge-0.1.1.dist-info/RECORD +21 -0
- brightdata_hotdata_bridge-0.1.1.dist-info/WHEEL +4 -0
- brightdata_hotdata_bridge-0.1.1.dist-info/entry_points.txt +2 -0
- brightdata_hotdata_bridge-0.1.1.dist-info/licenses/LICENSE +202 -0
- brightdata_hotdata_bridge-0.1.1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Collect data with Bright Data scrapers and publish it into Hotdata tables.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from brightdata_hotdata_bridge import push_snapshot_to_hotdata
|
|
6
|
+
|
|
7
|
+
result = push_snapshot_to_hotdata(
|
|
8
|
+
dataset_id="gd_l7q7dkf244hwjntr0",
|
|
9
|
+
queries=["https://www.amazon.com/dp/B0CRMZHDG8"],
|
|
10
|
+
table_name="amazon_products",
|
|
11
|
+
)
|
|
12
|
+
print(result.table_row_count)
|
|
13
|
+
|
|
14
|
+
Async code should use :func:`arun_pipeline` with a :class:`PushRequest` instead.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from brightdata_hotdata_bridge.__about__ import __version__
|
|
18
|
+
from brightdata_hotdata_bridge.catalog import (
|
|
19
|
+
get_scraper,
|
|
20
|
+
load_catalog,
|
|
21
|
+
resolve_method,
|
|
22
|
+
search_scrapers,
|
|
23
|
+
)
|
|
24
|
+
from brightdata_hotdata_bridge.config import Settings, get_settings
|
|
25
|
+
from brightdata_hotdata_bridge.errors import (
|
|
26
|
+
BridgeError,
|
|
27
|
+
BrightDataApiError,
|
|
28
|
+
CatalogError,
|
|
29
|
+
CollectionFailedError,
|
|
30
|
+
ConfigurationError,
|
|
31
|
+
EmptySnapshotError,
|
|
32
|
+
HotdataWriteError,
|
|
33
|
+
InputValidationError,
|
|
34
|
+
RateLimitedError,
|
|
35
|
+
RunStateError,
|
|
36
|
+
SchemaMismatchError,
|
|
37
|
+
SnapshotTimeoutError,
|
|
38
|
+
UnknownDatasetError,
|
|
39
|
+
UnsupportedMethodError,
|
|
40
|
+
)
|
|
41
|
+
from brightdata_hotdata_bridge.hotdata_client import find_database
|
|
42
|
+
from brightdata_hotdata_bridge.models import (
|
|
43
|
+
CollectionRequest,
|
|
44
|
+
HotdataDatabase,
|
|
45
|
+
PushRequest,
|
|
46
|
+
PushResult,
|
|
47
|
+
TableTarget,
|
|
48
|
+
ValidationOptions,
|
|
49
|
+
ValidationReport,
|
|
50
|
+
)
|
|
51
|
+
from brightdata_hotdata_bridge.pipeline import (
|
|
52
|
+
RunStatus,
|
|
53
|
+
aget_status,
|
|
54
|
+
areplay_run,
|
|
55
|
+
arun_pipeline,
|
|
56
|
+
astart_collection,
|
|
57
|
+
build_collection_request,
|
|
58
|
+
push_snapshot_to_hotdata,
|
|
59
|
+
replay_run,
|
|
60
|
+
)
|
|
61
|
+
from brightdata_hotdata_bridge.state import RunRecord, RunStage, list_runs, load_run
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"BridgeError",
|
|
65
|
+
"BrightDataApiError",
|
|
66
|
+
"CatalogError",
|
|
67
|
+
"CollectionFailedError",
|
|
68
|
+
"CollectionRequest",
|
|
69
|
+
"ConfigurationError",
|
|
70
|
+
"EmptySnapshotError",
|
|
71
|
+
"HotdataDatabase",
|
|
72
|
+
"HotdataWriteError",
|
|
73
|
+
"InputValidationError",
|
|
74
|
+
"PushRequest",
|
|
75
|
+
"PushResult",
|
|
76
|
+
"RateLimitedError",
|
|
77
|
+
"RunRecord",
|
|
78
|
+
"RunStage",
|
|
79
|
+
"RunStateError",
|
|
80
|
+
"RunStatus",
|
|
81
|
+
"SchemaMismatchError",
|
|
82
|
+
"Settings",
|
|
83
|
+
"SnapshotTimeoutError",
|
|
84
|
+
"TableTarget",
|
|
85
|
+
"UnknownDatasetError",
|
|
86
|
+
"UnsupportedMethodError",
|
|
87
|
+
"ValidationOptions",
|
|
88
|
+
"ValidationReport",
|
|
89
|
+
"__version__",
|
|
90
|
+
"aget_status",
|
|
91
|
+
"areplay_run",
|
|
92
|
+
"arun_pipeline",
|
|
93
|
+
"astart_collection",
|
|
94
|
+
"build_collection_request",
|
|
95
|
+
"find_database",
|
|
96
|
+
"get_scraper",
|
|
97
|
+
"get_settings",
|
|
98
|
+
"list_runs",
|
|
99
|
+
"load_catalog",
|
|
100
|
+
"load_run",
|
|
101
|
+
"push_snapshot_to_hotdata",
|
|
102
|
+
"replay_run",
|
|
103
|
+
"resolve_method",
|
|
104
|
+
"search_scrapers",
|
|
105
|
+
]
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Async client for the Bright Data Scraper API (``/datasets/v3``).
|
|
2
|
+
|
|
3
|
+
Three calls drive a collection: ``trigger`` starts it and returns a ``snapshot_id``,
|
|
4
|
+
``progress`` reports its status, and ``snapshot`` downloads the results once ready.
|
|
5
|
+
|
|
6
|
+
Rate limiting is handled conservatively. Bright Data blacklists an IP after 25
|
|
7
|
+
``429`` responses in five minutes, so every ``429`` waits for ``Retry-After`` (or 2, 4,
|
|
8
|
+
8, 16 then 32 seconds) before one retry. ``trigger`` is not idempotent, so it is
|
|
9
|
+
only retried when the request provably never reached the server.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
18
|
+
from http import HTTPStatus
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from brightdata_hotdata_bridge.__about__ import __version__
|
|
25
|
+
from brightdata_hotdata_bridge.config import Settings, require_setting
|
|
26
|
+
from brightdata_hotdata_bridge.errors import (
|
|
27
|
+
BrightDataApiError,
|
|
28
|
+
CollectionFailedError,
|
|
29
|
+
RateLimitedError,
|
|
30
|
+
SnapshotTimeoutError,
|
|
31
|
+
)
|
|
32
|
+
from brightdata_hotdata_bridge.models import (
|
|
33
|
+
CollectionMethod,
|
|
34
|
+
SnapshotProgress,
|
|
35
|
+
validate_snapshot_id,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
BACKOFF_SECONDS: tuple[float, ...] = (2, 4, 8, 16, 32)
|
|
41
|
+
MAX_RETRY_AFTER_SECONDS = 120.0
|
|
42
|
+
MAX_ERROR_BODY_CHARS = 2000
|
|
43
|
+
DOWNLOAD_CHUNK_BYTES = 1024 * 1024
|
|
44
|
+
SNAPSHOT_FORMAT = "ndjson"
|
|
45
|
+
|
|
46
|
+
ProgressCallback = Callable[[SnapshotProgress], Awaitable[None] | None]
|
|
47
|
+
Sleep = Callable[[float], Awaitable[None]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_brightdata_client(settings: Settings) -> httpx.AsyncClient:
|
|
51
|
+
"""Create an HTTP client authenticated against the Bright Data API.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
settings: Supplies the API key, base URL and timeout.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A client the caller must close (use it as an ``async with`` context manager).
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ConfigurationError: ``BRIGHTDATA_API_KEY`` is not set.
|
|
61
|
+
"""
|
|
62
|
+
api_key = require_setting(settings.brightdata_api_key, "BRIGHTDATA_API_KEY")
|
|
63
|
+
return httpx.AsyncClient(
|
|
64
|
+
base_url=settings.brightdata_base_url,
|
|
65
|
+
headers={
|
|
66
|
+
"Authorization": f"Bearer {api_key.get_secret_value()}",
|
|
67
|
+
"User-Agent": f"brightdata-hotdata-bridge/{__version__}",
|
|
68
|
+
},
|
|
69
|
+
timeout=httpx.Timeout(settings.http_timeout_seconds),
|
|
70
|
+
follow_redirects=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def trigger_collection(
|
|
75
|
+
client: httpx.AsyncClient,
|
|
76
|
+
*,
|
|
77
|
+
dataset_id: str,
|
|
78
|
+
method: CollectionMethod,
|
|
79
|
+
inputs: Sequence[dict[str, Any]],
|
|
80
|
+
limit_per_input: int | None = None,
|
|
81
|
+
sleep: Sleep = asyncio.sleep,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Start an asynchronous collection and return its snapshot id.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
client: Client from :func:`create_brightdata_client`.
|
|
87
|
+
dataset_id: Scraper to run.
|
|
88
|
+
method: Collection method; discovery methods add ``type=discover_new`` and
|
|
89
|
+
``discover_by=<suffix>`` to the request.
|
|
90
|
+
inputs: Validated input objects, one per URL or query.
|
|
91
|
+
limit_per_input: Cap on records per input for discovery methods.
|
|
92
|
+
sleep: Awaitable sleep, injectable for tests.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The ``snapshot_id`` identifying the collection.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
BrightDataApiError: Bright Data rejected the request or returned no snapshot id.
|
|
99
|
+
RateLimitedError: Still rate limited after every backoff step.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
``POST /datasets/v3/trigger?dataset_id=gd_...&include_errors=true``
|
|
103
|
+
with body ``{"input": [{"url": "https://..."}]}``.
|
|
104
|
+
"""
|
|
105
|
+
params: dict[str, str] = {"dataset_id": dataset_id, "include_errors": "true"}
|
|
106
|
+
if method.discover_by is not None:
|
|
107
|
+
params |= {"type": "discover_new", "discover_by": method.discover_by}
|
|
108
|
+
body: dict[str, Any] = {"input": list(inputs)}
|
|
109
|
+
if limit_per_input is not None:
|
|
110
|
+
body["limit_per_input"] = limit_per_input
|
|
111
|
+
|
|
112
|
+
response = await _send(
|
|
113
|
+
client,
|
|
114
|
+
"POST",
|
|
115
|
+
"/datasets/v3/trigger",
|
|
116
|
+
idempotent=False,
|
|
117
|
+
sleep=sleep,
|
|
118
|
+
params=params,
|
|
119
|
+
json=body,
|
|
120
|
+
)
|
|
121
|
+
if response.status_code != HTTPStatus.OK:
|
|
122
|
+
raise _api_error("Bright Data rejected the collection request", response)
|
|
123
|
+
|
|
124
|
+
snapshot_id = _json(response).get("snapshot_id")
|
|
125
|
+
if not isinstance(snapshot_id, str):
|
|
126
|
+
raise _api_error("Bright Data did not return a snapshot_id", response)
|
|
127
|
+
validate_snapshot_id(snapshot_id)
|
|
128
|
+
logger.info("Collection triggered", extra={"snapshot_id": snapshot_id, "rows": len(inputs)})
|
|
129
|
+
return snapshot_id
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def get_progress(
|
|
133
|
+
client: httpx.AsyncClient, snapshot_id: str, *, sleep: Sleep = asyncio.sleep
|
|
134
|
+
) -> SnapshotProgress:
|
|
135
|
+
"""Fetch the current status of a snapshot.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
client: Client from :func:`create_brightdata_client`.
|
|
139
|
+
snapshot_id: Snapshot to inspect.
|
|
140
|
+
sleep: Awaitable sleep, injectable for tests.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The snapshot status: ``starting``, ``running``, ``ready``, ``failed`` or ``canceled``.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
BrightDataApiError: The status could not be retrieved.
|
|
147
|
+
RateLimitedError: Still rate limited after every backoff step.
|
|
148
|
+
"""
|
|
149
|
+
response = await _send(
|
|
150
|
+
client,
|
|
151
|
+
"GET",
|
|
152
|
+
f"/datasets/v3/progress/{validate_snapshot_id(snapshot_id)}",
|
|
153
|
+
idempotent=True,
|
|
154
|
+
sleep=sleep,
|
|
155
|
+
)
|
|
156
|
+
if response.status_code != HTTPStatus.OK:
|
|
157
|
+
raise _api_error("Could not read snapshot progress", response, snapshot_id=snapshot_id)
|
|
158
|
+
return SnapshotProgress.model_validate({**_json(response), "snapshot_id": snapshot_id})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def wait_for_snapshot(
|
|
162
|
+
client: httpx.AsyncClient,
|
|
163
|
+
snapshot_id: str,
|
|
164
|
+
*,
|
|
165
|
+
poll_interval: float,
|
|
166
|
+
timeout: float,
|
|
167
|
+
on_progress: ProgressCallback | None = None,
|
|
168
|
+
sleep: Sleep = asyncio.sleep,
|
|
169
|
+
clock: Callable[[], float] = time.monotonic,
|
|
170
|
+
) -> SnapshotProgress:
|
|
171
|
+
"""Poll a snapshot until it is ready, failed, or the timeout expires.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
client: Client from :func:`create_brightdata_client`.
|
|
175
|
+
snapshot_id: Snapshot to wait for.
|
|
176
|
+
poll_interval: Seconds between status checks.
|
|
177
|
+
timeout: Maximum seconds to wait.
|
|
178
|
+
on_progress: Called with every status update (sync or async).
|
|
179
|
+
sleep: Awaitable sleep, injectable for tests.
|
|
180
|
+
clock: Monotonic clock, injectable for tests.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The final ``ready`` progress.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
CollectionFailedError: Bright Data reports ``failed`` or ``canceled``. No data
|
|
187
|
+
should be written for this snapshot.
|
|
188
|
+
SnapshotTimeoutError: Still not ready after ``timeout`` seconds. Polling can be
|
|
189
|
+
resumed later with the same snapshot id.
|
|
190
|
+
BrightDataApiError: Status checks keep failing.
|
|
191
|
+
"""
|
|
192
|
+
deadline = clock() + timeout
|
|
193
|
+
while True:
|
|
194
|
+
progress = await get_progress(client, snapshot_id, sleep=sleep)
|
|
195
|
+
if on_progress is not None:
|
|
196
|
+
outcome = on_progress(progress)
|
|
197
|
+
if outcome is not None:
|
|
198
|
+
await outcome
|
|
199
|
+
if progress.is_ready:
|
|
200
|
+
return progress
|
|
201
|
+
if progress.is_failed:
|
|
202
|
+
detail = f": {progress.error_message}" if progress.error_message else ""
|
|
203
|
+
raise CollectionFailedError(
|
|
204
|
+
f"Bright Data collection ended as {progress.status}{detail}",
|
|
205
|
+
snapshot_id=snapshot_id,
|
|
206
|
+
status=progress.status,
|
|
207
|
+
)
|
|
208
|
+
if clock() + poll_interval > deadline:
|
|
209
|
+
raise SnapshotTimeoutError(
|
|
210
|
+
f"Snapshot not ready after {timeout:.0f}s (last status: {progress.status}). "
|
|
211
|
+
"Run `bdh push --snapshot-id` again later to resume.",
|
|
212
|
+
snapshot_id=snapshot_id,
|
|
213
|
+
)
|
|
214
|
+
await sleep(poll_interval)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def download_snapshot(
|
|
218
|
+
client: httpx.AsyncClient,
|
|
219
|
+
snapshot_id: str,
|
|
220
|
+
destination: Path,
|
|
221
|
+
*,
|
|
222
|
+
poll_interval: float,
|
|
223
|
+
timeout: float,
|
|
224
|
+
sleep: Sleep = asyncio.sleep,
|
|
225
|
+
clock: Callable[[], float] = time.monotonic,
|
|
226
|
+
) -> Path:
|
|
227
|
+
"""Stream a ready snapshot to ``destination`` as NDJSON without buffering it in memory.
|
|
228
|
+
|
|
229
|
+
The file is written to a temporary name and renamed on success, so a partial
|
|
230
|
+
download never looks complete. A ``202`` (snapshot still being built) is retried
|
|
231
|
+
until ``timeout``.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
client: Client from :func:`create_brightdata_client`.
|
|
235
|
+
snapshot_id: Ready snapshot to download.
|
|
236
|
+
destination: Final file path.
|
|
237
|
+
poll_interval: Seconds between retries while the snapshot is still building.
|
|
238
|
+
timeout: Maximum seconds to wait for the snapshot to become downloadable.
|
|
239
|
+
sleep: Awaitable sleep, injectable for tests.
|
|
240
|
+
clock: Monotonic clock, injectable for tests.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
``destination``.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
BrightDataApiError: The download failed.
|
|
247
|
+
SnapshotTimeoutError: The snapshot stayed in the building state too long.
|
|
248
|
+
RateLimitedError: Still rate limited after every backoff step.
|
|
249
|
+
"""
|
|
250
|
+
url = f"/datasets/v3/snapshot/{validate_snapshot_id(snapshot_id)}"
|
|
251
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
temp_path = destination.with_suffix(destination.suffix + ".part")
|
|
253
|
+
deadline = clock() + timeout
|
|
254
|
+
backoff = iter(BACKOFF_SECONDS)
|
|
255
|
+
|
|
256
|
+
while True:
|
|
257
|
+
try:
|
|
258
|
+
async with client.stream("GET", url, params={"format": SNAPSHOT_FORMAT}) as response:
|
|
259
|
+
if response.status_code == HTTPStatus.OK:
|
|
260
|
+
await _stream_to_file(response, temp_path)
|
|
261
|
+
temp_path.replace(destination)
|
|
262
|
+
logger.info("Snapshot downloaded", extra={"snapshot_id": snapshot_id})
|
|
263
|
+
return destination
|
|
264
|
+
await response.aread()
|
|
265
|
+
except httpx.TransportError as exc:
|
|
266
|
+
delay = next(backoff, None)
|
|
267
|
+
if delay is None:
|
|
268
|
+
raise BrightDataApiError(
|
|
269
|
+
f"Snapshot download failed: {exc}", snapshot_id=snapshot_id
|
|
270
|
+
) from exc
|
|
271
|
+
logger.warning("Download interrupted (%s); retrying in %ss", exc, delay)
|
|
272
|
+
await sleep(delay)
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
if response.status_code == HTTPStatus.ACCEPTED:
|
|
276
|
+
if clock() + poll_interval > deadline:
|
|
277
|
+
raise SnapshotTimeoutError(
|
|
278
|
+
"Snapshot is still being built; try again later", snapshot_id=snapshot_id
|
|
279
|
+
)
|
|
280
|
+
await sleep(poll_interval)
|
|
281
|
+
continue
|
|
282
|
+
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
|
283
|
+
await sleep(_rate_limit_delay(response, next(backoff, None), snapshot_id))
|
|
284
|
+
continue
|
|
285
|
+
raise _api_error("Snapshot download failed", response, snapshot_id=snapshot_id)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def _stream_to_file(response: httpx.Response, path: Path) -> None:
|
|
289
|
+
with path.open("wb") as handle:
|
|
290
|
+
async for chunk in response.aiter_bytes(DOWNLOAD_CHUNK_BYTES):
|
|
291
|
+
handle.write(chunk)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def _send(
|
|
295
|
+
client: httpx.AsyncClient,
|
|
296
|
+
method: str,
|
|
297
|
+
url: str,
|
|
298
|
+
*,
|
|
299
|
+
idempotent: bool,
|
|
300
|
+
sleep: Sleep,
|
|
301
|
+
**kwargs: Any,
|
|
302
|
+
) -> httpx.Response:
|
|
303
|
+
backoff = iter(BACKOFF_SECONDS)
|
|
304
|
+
while True:
|
|
305
|
+
try:
|
|
306
|
+
response = await client.request(method, url, **kwargs)
|
|
307
|
+
except httpx.TransportError as exc:
|
|
308
|
+
never_sent = isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout))
|
|
309
|
+
delay = next(backoff, None)
|
|
310
|
+
if not (idempotent or never_sent) or delay is None:
|
|
311
|
+
raise BrightDataApiError(f"Could not reach Bright Data: {exc}") from exc
|
|
312
|
+
logger.warning("Bright Data unreachable (%s); retrying in %ss", exc, delay)
|
|
313
|
+
await sleep(delay)
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
|
317
|
+
await sleep(_rate_limit_delay(response, next(backoff, None), None))
|
|
318
|
+
continue
|
|
319
|
+
if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR and idempotent:
|
|
320
|
+
delay = next(backoff, None)
|
|
321
|
+
if delay is not None:
|
|
322
|
+
logger.warning(
|
|
323
|
+
"Bright Data returned %s; retrying in %ss", response.status_code, delay
|
|
324
|
+
)
|
|
325
|
+
await sleep(delay)
|
|
326
|
+
continue
|
|
327
|
+
return response
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _rate_limit_delay(
|
|
331
|
+
response: httpx.Response, fallback: float | None, snapshot_id: str | None
|
|
332
|
+
) -> float:
|
|
333
|
+
if fallback is None:
|
|
334
|
+
raise RateLimitedError(
|
|
335
|
+
"Bright Data is still rate limiting after repeated backoff; slow down and retry later",
|
|
336
|
+
status_code=response.status_code,
|
|
337
|
+
snapshot_id=snapshot_id,
|
|
338
|
+
)
|
|
339
|
+
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
|
|
340
|
+
delay = min(retry_after, MAX_RETRY_AFTER_SECONDS) if retry_after is not None else fallback
|
|
341
|
+
logger.warning("Bright Data rate limit hit; waiting %ss", delay)
|
|
342
|
+
return delay
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
346
|
+
if value is None:
|
|
347
|
+
return None
|
|
348
|
+
try:
|
|
349
|
+
return max(float(value), 0.0)
|
|
350
|
+
except ValueError:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _json(response: httpx.Response) -> dict[str, Any]:
|
|
355
|
+
try:
|
|
356
|
+
payload = response.json()
|
|
357
|
+
except ValueError:
|
|
358
|
+
return {}
|
|
359
|
+
return payload if isinstance(payload, dict) else {}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _api_error(
|
|
363
|
+
message: str, response: httpx.Response, *, snapshot_id: str | None = None
|
|
364
|
+
) -> BrightDataApiError:
|
|
365
|
+
body = response.text[:MAX_ERROR_BODY_CHARS]
|
|
366
|
+
return BrightDataApiError(
|
|
367
|
+
f"{message} (HTTP {response.status_code}): {body}",
|
|
368
|
+
status_code=response.status_code,
|
|
369
|
+
body=body,
|
|
370
|
+
snapshot_id=snapshot_id,
|
|
371
|
+
)
|