scrapely-client-python 1.0.0__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.

Potentially problematic release.


This version of scrapely-client-python might be problematic. Click here for more details.

Files changed (65) hide show
  1. scrapely_client/__init__.py +30 -0
  2. scrapely_client/_charging.py +186 -0
  3. scrapely_client/_client_registry.py +136 -0
  4. scrapely_client/_consts.py +96 -0
  5. scrapely_client/_docs.py +37 -0
  6. scrapely_client/_literals.py +539 -0
  7. scrapely_client/_logging.py +180 -0
  8. scrapely_client/_models.py +4172 -0
  9. scrapely_client/_pagination.py +220 -0
  10. scrapely_client/_resource_clients/__init__.py +84 -0
  11. scrapely_client/_resource_clients/_resource_client.py +567 -0
  12. scrapely_client/_resource_clients/actor.py +1042 -0
  13. scrapely_client/_resource_clients/actor_collection.py +377 -0
  14. scrapely_client/_resource_clients/actor_env_var.py +154 -0
  15. scrapely_client/_resource_clients/actor_env_var_collection.py +165 -0
  16. scrapely_client/_resource_clients/actor_version.py +254 -0
  17. scrapely_client/_resource_clients/actor_version_collection.py +232 -0
  18. scrapely_client/_resource_clients/build.py +244 -0
  19. scrapely_client/_resource_clients/build_collection.py +164 -0
  20. scrapely_client/_resource_clients/dataset.py +1151 -0
  21. scrapely_client/_resource_clients/dataset_collection.py +244 -0
  22. scrapely_client/_resource_clients/key_value_store.py +906 -0
  23. scrapely_client/_resource_clients/key_value_store_collection.py +243 -0
  24. scrapely_client/_resource_clients/log.py +225 -0
  25. scrapely_client/_resource_clients/request_queue.py +1167 -0
  26. scrapely_client/_resource_clients/request_queue_collection.py +240 -0
  27. scrapely_client/_resource_clients/run.py +875 -0
  28. scrapely_client/_resource_clients/run_collection.py +226 -0
  29. scrapely_client/_resource_clients/schedule.py +243 -0
  30. scrapely_client/_resource_clients/schedule_collection.py +260 -0
  31. scrapely_client/_resource_clients/store_collection.py +242 -0
  32. scrapely_client/_resource_clients/task.py +793 -0
  33. scrapely_client/_resource_clients/task_collection.py +322 -0
  34. scrapely_client/_resource_clients/user.py +262 -0
  35. scrapely_client/_resource_clients/webhook.py +292 -0
  36. scrapely_client/_resource_clients/webhook_collection.py +281 -0
  37. scrapely_client/_resource_clients/webhook_dispatch.py +84 -0
  38. scrapely_client/_resource_clients/webhook_dispatch_collection.py +158 -0
  39. scrapely_client/_scrapely_client.py +837 -0
  40. scrapely_client/_statistics.py +29 -0
  41. scrapely_client/_status_message_watcher.py +197 -0
  42. scrapely_client/_streamed_log.py +250 -0
  43. scrapely_client/_typeddicts.py +408 -0
  44. scrapely_client/_utils/__init__.py +0 -0
  45. scrapely_client/_utils/crypto.py +87 -0
  46. scrapely_client/_utils/encoding.py +133 -0
  47. scrapely_client/_utils/errors.py +33 -0
  48. scrapely_client/_utils/http.py +137 -0
  49. scrapely_client/_utils/time.py +32 -0
  50. scrapely_client/_utils/try_import.py +61 -0
  51. scrapely_client/errors.py +190 -0
  52. scrapely_client/http_clients/__init__.py +10 -0
  53. scrapely_client/http_clients/_base.py +430 -0
  54. scrapely_client/http_clients/_impit.py +598 -0
  55. scrapely_client/http_compressors/__init__.py +18 -0
  56. scrapely_client/http_compressors/_base.py +25 -0
  57. scrapely_client/http_compressors/_brotli.py +38 -0
  58. scrapely_client/http_compressors/_gzip.py +38 -0
  59. scrapely_client/http_compressors/_resolve.py +38 -0
  60. scrapely_client/py.typed +0 -0
  61. scrapely_client/types.py +52 -0
  62. scrapely_client_python-1.0.0.dist-info/METADATA +244 -0
  63. scrapely_client_python-1.0.0.dist-info/RECORD +65 -0
  64. scrapely_client_python-1.0.0.dist-info/WHEEL +4 -0
  65. scrapely_client_python-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,567 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from datetime import UTC, datetime, timedelta
6
+ from functools import cached_property
7
+ from typing import TYPE_CHECKING, Any, Literal, get_args
8
+ from urllib.parse import urlencode, urlparse, urlunparse
9
+
10
+ from scrapely_client._consts import DEFAULT_WAIT_FOR_FINISH, DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
11
+ from scrapely_client._docs import docs_group
12
+ from scrapely_client._logging import WithLogDetailsClient
13
+ from scrapely_client._utils.errors import catch_not_found_for_resource_or_throw, catch_not_found_or_throw
14
+ from scrapely_client._utils.http import response_to_dict, to_safe_id
15
+ from scrapely_client._utils.time import to_seconds
16
+ from scrapely_client.errors import ApifyApiError
17
+
18
+ if TYPE_CHECKING:
19
+ from scrapely_client._client_registry import ClientRegistry, ClientRegistryAsync
20
+ from scrapely_client.http_clients import HttpClient, HttpClientAsync
21
+ from scrapely_client.types import Timeout
22
+
23
+ _TerminalActorJobStatus = Literal['SUCCEEDED', 'FAILED', 'TIMED-OUT', 'ABORTED']
24
+ """Subset of `ActorJobStatus` values that indicate the job has finished and will not change again."""
25
+
26
+ _TERMINAL_STATUSES: frozenset[_TerminalActorJobStatus] = frozenset(get_args(_TerminalActorJobStatus))
27
+
28
+
29
+ class ResourceClientBase(metaclass=WithLogDetailsClient):
30
+ """Base class with shared implementation for sync and async resource clients.
31
+
32
+ Provides URL building, parameter handling, and client creation utilities.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ base_url: str,
39
+ public_base_url: str,
40
+ http_client: Any,
41
+ resource_path: str,
42
+ client_registry: Any,
43
+ resource_id: str | None = None,
44
+ params: dict | None = None,
45
+ ) -> None:
46
+ """Initialize the resource client.
47
+
48
+ Args:
49
+ base_url: API base URL.
50
+ public_base_url: Public CDN base URL.
51
+ http_client: HTTP client for making requests.
52
+ resource_path: Resource endpoint path (e.g., 'actors', 'datasets').
53
+ client_registry: Bundle of client classes for dependency injection.
54
+ resource_id: Optional resource ID for single-resource clients.
55
+ params: Optional default parameters for all requests.
56
+ """
57
+ if resource_path.endswith('/'):
58
+ raise ValueError('resource_path must not end with "/"')
59
+
60
+ self._base_url = base_url
61
+ self._public_base_url = public_base_url
62
+ self._http_client = http_client
63
+ self._default_params = params or {}
64
+ self._resource_path = resource_path
65
+ self._resource_id = resource_id
66
+ self._client_registry = client_registry
67
+
68
+ @property
69
+ def resource_id(self) -> str | None:
70
+ """Get the resource ID."""
71
+ return self._resource_id
72
+
73
+ @property
74
+ def _resource_url(self) -> str:
75
+ """Build the full resource URL from base URL, path, and optional ID."""
76
+ url = f'{self._base_url}/{self._resource_path}'
77
+ if self._resource_id is not None:
78
+ url = f'{url}/{to_safe_id(self._resource_id)}'
79
+ return url
80
+
81
+ @cached_property
82
+ def _base_client_kwargs(self) -> dict[str, Any]:
83
+ """Base kwargs for creating nested/child clients.
84
+
85
+ Returns dict with base_url, public_base_url, http_client, and client_registry. Caller adds
86
+ resource_path, resource_id, and params as needed.
87
+ """
88
+ return {
89
+ 'base_url': self._resource_url,
90
+ 'public_base_url': self._public_base_url,
91
+ 'http_client': self._http_client,
92
+ 'client_registry': self._client_registry,
93
+ }
94
+
95
+ def _build_url(
96
+ self,
97
+ path: str | None = None,
98
+ *,
99
+ public: bool = False,
100
+ ) -> str:
101
+ """Build complete URL for API request.
102
+
103
+ Args:
104
+ path: Optional path segment to append (e.g., 'runs', 'items').
105
+ public: Whether to use public CDN URL instead of API URL.
106
+
107
+ Returns:
108
+ Complete URL with optional path and query string.
109
+ """
110
+ url = f'{self._resource_url}/{path}' if path is not None else self._resource_url
111
+
112
+ if public:
113
+ if not url.startswith(self._base_url):
114
+ raise ValueError(f'URL {url} does not start with base URL {self._base_url}')
115
+ url = url.replace(self._base_url, self._public_base_url, 1)
116
+
117
+ return url
118
+
119
+ def _build_public_url(self, path: str, params: dict[str, Any]) -> str:
120
+ """Build a public resource URL with API-normalized query params.
121
+
122
+ Normalizes `params` the same way the HTTP request path does (bool -> true/false, list -> comma-joined,
123
+ datetime -> ISO 8601 Zulu) so the shareable URL matches what the client would send over the wire.
124
+
125
+ Args:
126
+ path: Path segment appended to the resource URL (e.g. 'items', 'keys').
127
+ params: Query parameters to normalize and append.
128
+
129
+ Returns:
130
+ The public URL with a normalized query string.
131
+ """
132
+ public_url = urlparse(self._build_url(path, public=True))
133
+ filtered_params = self._http_client._parse_params(params) # noqa: SLF001
134
+ if filtered_params:
135
+ public_url = public_url._replace(query=urlencode(filtered_params))
136
+ return urlunparse(public_url)
137
+
138
+ def _build_params(self, **kwargs: Any) -> dict:
139
+ """Merge default params with method params, filtering out None values.
140
+
141
+ Args:
142
+ **kwargs: Method-specific parameters to merge.
143
+
144
+ Returns:
145
+ Merged parameters with None values removed.
146
+ """
147
+ merged = {**self._default_params, **kwargs}
148
+ return {k: v for k, v in merged.items() if v is not None}
149
+
150
+ @staticmethod
151
+ def _clean_json_payload(data: dict) -> dict:
152
+ """Remove None values and empty nested dicts from an API request payload.
153
+
154
+ The Scrapely API ignores missing fields but may reject fields explicitly set to None.
155
+ Nested sub-models serialized by Pydantic may produce empty dicts when all their
156
+ fields are None - these are also removed.
157
+
158
+ Uses an iterative stack-based approach, analogous to _build_params for query params.
159
+ """
160
+ result: dict = {}
161
+ stack: list[tuple[dict, dict]] = [(data, result)]
162
+
163
+ while stack:
164
+ source, target = stack.pop()
165
+ for key, val in source.items():
166
+ if val is None:
167
+ continue
168
+ if isinstance(val, dict):
169
+ nested: dict = {}
170
+ target[key] = nested
171
+ stack.append((val, nested))
172
+ else:
173
+ target[key] = val
174
+
175
+ # Remove dicts that became empty after None filtering
176
+ def _remove_empty(d: dict) -> None:
177
+ for key in [k for k, v in d.items() if isinstance(v, dict)]:
178
+ _remove_empty(d[key])
179
+ if not d[key]:
180
+ del d[key]
181
+
182
+ _remove_empty(result)
183
+ return result
184
+
185
+
186
+ @docs_group('Resource clients')
187
+ class ResourceClient(ResourceClientBase):
188
+ """Base class for synchronous resource clients."""
189
+
190
+ def __init__(
191
+ self,
192
+ *,
193
+ base_url: str,
194
+ public_base_url: str,
195
+ http_client: HttpClient,
196
+ resource_path: str,
197
+ client_registry: ClientRegistry,
198
+ resource_id: str | None = None,
199
+ params: dict | None = None,
200
+ ) -> None:
201
+ """Initialize the resource client.
202
+
203
+ Args:
204
+ base_url: API base URL.
205
+ public_base_url: Public CDN base URL.
206
+ http_client: HTTP client for making requests.
207
+ resource_path: Resource endpoint path (e.g., 'actors', 'datasets').
208
+ client_registry: Bundle of client classes for dependency injection.
209
+ resource_id: Optional resource ID for single-resource clients.
210
+ params: Optional default parameters for all requests.
211
+ """
212
+ super().__init__(
213
+ base_url=base_url,
214
+ public_base_url=public_base_url,
215
+ http_client=http_client,
216
+ resource_path=resource_path,
217
+ client_registry=client_registry,
218
+ resource_id=resource_id,
219
+ params=params,
220
+ )
221
+
222
+ def _get(self, *, timeout: Timeout) -> dict | None:
223
+ """Perform a GET request for this resource, returning the parsed response or None if not found.
224
+
225
+ 404s collapse to `None` only for ID-identified clients. Chained clients without a `resource_id`
226
+ (e.g. `run.dataset()`) propagate `NotFoundError` - see `catch_not_found_for_resource_or_throw`.
227
+ """
228
+ try:
229
+ response = self._http_client.call(
230
+ url=self._build_url(),
231
+ method='GET',
232
+ params=self._build_params(),
233
+ timeout=timeout,
234
+ )
235
+ return response_to_dict(response)
236
+ except ApifyApiError as exc:
237
+ catch_not_found_for_resource_or_throw(exc, self._resource_id)
238
+ return None
239
+
240
+ def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
241
+ """Perform a PUT request to update this resource with the given fields."""
242
+ response = self._http_client.call(
243
+ url=self._build_url(),
244
+ method='PUT',
245
+ params=self._build_params(),
246
+ json=self._clean_json_payload(kwargs),
247
+ timeout=timeout,
248
+ )
249
+ return response_to_dict(response)
250
+
251
+ def _delete(self, *, timeout: Timeout) -> None:
252
+ """Perform a DELETE request to delete this resource.
253
+
254
+ 404s are swallowed (idempotent DELETE) only for ID-identified clients. Chained clients without a
255
+ `resource_id` propagate `NotFoundError` - see `catch_not_found_for_resource_or_throw`.
256
+ """
257
+ try:
258
+ self._http_client.call(
259
+ url=self._build_url(),
260
+ method='DELETE',
261
+ params=self._build_params(),
262
+ timeout=timeout,
263
+ )
264
+ except ApifyApiError as exc:
265
+ catch_not_found_for_resource_or_throw(exc, self._resource_id)
266
+
267
+ def _list(self, *, timeout: Timeout, **kwargs: Any) -> dict:
268
+ """Perform a GET request to list resources."""
269
+ response = self._http_client.call(
270
+ url=self._build_url(),
271
+ method='GET',
272
+ params=self._build_params(**kwargs),
273
+ timeout=timeout,
274
+ )
275
+ return response_to_dict(response)
276
+
277
+ def _create(self, *, timeout: Timeout, **kwargs: Any) -> dict:
278
+ """Perform a POST request to create a resource."""
279
+ response = self._http_client.call(
280
+ url=self._build_url(),
281
+ method='POST',
282
+ params=self._build_params(),
283
+ json=self._clean_json_payload(kwargs),
284
+ timeout=timeout,
285
+ )
286
+ return response_to_dict(response)
287
+
288
+ def _get_or_create(
289
+ self,
290
+ *,
291
+ name: str | None = None,
292
+ resource_fields: dict | None = None,
293
+ timeout: Timeout,
294
+ ) -> dict:
295
+ """Perform a POST request to get or create a named resource."""
296
+ response = self._http_client.call(
297
+ url=self._build_url(),
298
+ method='POST',
299
+ params=self._build_params(name=name),
300
+ json=self._clean_json_payload(resource_fields) if resource_fields is not None else None,
301
+ timeout=timeout,
302
+ )
303
+ return response_to_dict(response)
304
+
305
+ def _wait_for_finish(
306
+ self,
307
+ *,
308
+ url: str,
309
+ params: dict,
310
+ timeout: Timeout,
311
+ wait_duration: timedelta | None = None,
312
+ ) -> dict | None:
313
+ """Wait synchronously for an Actor job (run or build) to finish.
314
+
315
+ Polls the job status until it reaches a terminal state or timeout.
316
+ Handles 404 errors gracefully (job might not exist yet in replicas).
317
+
318
+ Args:
319
+ url: Full URL to the job endpoint.
320
+ params: Base query parameters to include in each request.
321
+ timeout: Timeout for each individual HTTP request.
322
+ wait_duration: Maximum time to wait (None = indefinite).
323
+
324
+ Returns:
325
+ Job data dict when finished, or None if job doesn't exist after
326
+ DEFAULT_WAIT_WHEN_JOB_NOT_EXIST seconds.
327
+
328
+ Raises:
329
+ ApifyApiError: If API returns errors other than 404.
330
+ """
331
+ deadline = (datetime.now(UTC) + wait_duration) if wait_duration is not None else None
332
+ not_found_deadline: datetime | None = None
333
+ actor_job: dict = {}
334
+
335
+ while True:
336
+ if deadline is not None:
337
+ remaining_secs = max(0, int(to_seconds(deadline - datetime.now(UTC))))
338
+ wait_for_finish = remaining_secs
339
+ else:
340
+ wait_for_finish = to_seconds(DEFAULT_WAIT_FOR_FINISH, as_int=True)
341
+
342
+ try:
343
+ response = self._http_client.call(
344
+ url=url,
345
+ method='GET',
346
+ params={**params, 'waitForFinish': wait_for_finish},
347
+ timeout=timeout,
348
+ )
349
+ result = response_to_dict(response)
350
+ actor_job = result.get('data') or {}
351
+
352
+ # Reset the not-found streak so a later transient 404 gets its own grace window.
353
+ not_found_deadline = None
354
+
355
+ is_terminal = actor_job.get('status') in _TERMINAL_STATUSES
356
+ is_timed_out = deadline is not None and datetime.now(UTC) >= deadline
357
+
358
+ if is_terminal or is_timed_out:
359
+ break
360
+
361
+ except ApifyApiError as exc:
362
+ catch_not_found_or_throw(exc)
363
+
364
+ now = datetime.now(UTC)
365
+ if deadline is not None and now >= deadline:
366
+ return None
367
+ if not_found_deadline is None:
368
+ not_found_deadline = now + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
369
+ elif now > not_found_deadline:
370
+ return None
371
+
372
+ # It might take some time for database replicas to get up-to-date so sleep a bit before retrying
373
+ time.sleep(0.25)
374
+
375
+ return actor_job
376
+
377
+
378
+ @docs_group('Resource clients')
379
+ class ResourceClientAsync(ResourceClientBase):
380
+ """Base class for asynchronous resource clients."""
381
+
382
+ def __init__(
383
+ self,
384
+ *,
385
+ base_url: str,
386
+ public_base_url: str,
387
+ http_client: HttpClientAsync,
388
+ resource_path: str,
389
+ client_registry: ClientRegistryAsync,
390
+ resource_id: str | None = None,
391
+ params: dict | None = None,
392
+ ) -> None:
393
+ """Initialize the resource client.
394
+
395
+ Args:
396
+ base_url: API base URL.
397
+ public_base_url: Public CDN base URL.
398
+ http_client: HTTP client for making requests.
399
+ resource_path: Resource endpoint path (e.g., 'actors', 'datasets').
400
+ client_registry: Bundle of client classes for dependency injection.
401
+ resource_id: Optional resource ID for single-resource clients.
402
+ params: Optional default parameters for all requests.
403
+ """
404
+ super().__init__(
405
+ base_url=base_url,
406
+ public_base_url=public_base_url,
407
+ http_client=http_client,
408
+ resource_path=resource_path,
409
+ client_registry=client_registry,
410
+ resource_id=resource_id,
411
+ params=params,
412
+ )
413
+
414
+ async def _get(self, *, timeout: Timeout) -> dict | None:
415
+ """Perform a GET request for this resource, returning the parsed response or None if not found.
416
+
417
+ 404s collapse to `None` only for ID-identified clients. Chained clients without a `resource_id`
418
+ (e.g. `run.dataset()`) propagate `NotFoundError` - see `catch_not_found_for_resource_or_throw`.
419
+ """
420
+ try:
421
+ response = await self._http_client.call(
422
+ url=self._build_url(),
423
+ method='GET',
424
+ params=self._build_params(),
425
+ timeout=timeout,
426
+ )
427
+ return response_to_dict(response)
428
+ except ApifyApiError as exc:
429
+ catch_not_found_for_resource_or_throw(exc, self._resource_id)
430
+ return None
431
+
432
+ async def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
433
+ """Perform a PUT request to update this resource with the given fields."""
434
+ response = await self._http_client.call(
435
+ url=self._build_url(),
436
+ method='PUT',
437
+ params=self._build_params(),
438
+ json=self._clean_json_payload(kwargs),
439
+ timeout=timeout,
440
+ )
441
+ return response_to_dict(response)
442
+
443
+ async def _delete(self, *, timeout: Timeout) -> None:
444
+ """Perform a DELETE request to delete this resource.
445
+
446
+ 404s are swallowed (idempotent DELETE) only for ID-identified clients. Chained clients without a
447
+ `resource_id` propagate `NotFoundError` - see `catch_not_found_for_resource_or_throw`.
448
+ """
449
+ try:
450
+ await self._http_client.call(
451
+ url=self._build_url(),
452
+ method='DELETE',
453
+ params=self._build_params(),
454
+ timeout=timeout,
455
+ )
456
+ except ApifyApiError as exc:
457
+ catch_not_found_for_resource_or_throw(exc, self._resource_id)
458
+
459
+ async def _list(self, *, timeout: Timeout, **kwargs: Any) -> dict:
460
+ """Perform a GET request to list resources."""
461
+ response = await self._http_client.call(
462
+ url=self._build_url(),
463
+ method='GET',
464
+ params=self._build_params(**kwargs),
465
+ timeout=timeout,
466
+ )
467
+ return response_to_dict(response)
468
+
469
+ async def _create(self, *, timeout: Timeout, **kwargs: Any) -> dict:
470
+ """Perform a POST request to create a resource."""
471
+ response = await self._http_client.call(
472
+ url=self._build_url(),
473
+ method='POST',
474
+ params=self._build_params(),
475
+ json=self._clean_json_payload(kwargs),
476
+ timeout=timeout,
477
+ )
478
+ return response_to_dict(response)
479
+
480
+ async def _get_or_create(
481
+ self,
482
+ *,
483
+ name: str | None = None,
484
+ resource_fields: dict | None = None,
485
+ timeout: Timeout,
486
+ ) -> dict:
487
+ """Perform a POST request to get or create a named resource."""
488
+ response = await self._http_client.call(
489
+ url=self._build_url(),
490
+ method='POST',
491
+ params=self._build_params(name=name),
492
+ json=self._clean_json_payload(resource_fields) if resource_fields is not None else None,
493
+ timeout=timeout,
494
+ )
495
+ return response_to_dict(response)
496
+
497
+ async def _wait_for_finish(
498
+ self,
499
+ *,
500
+ url: str,
501
+ params: dict,
502
+ timeout: Timeout,
503
+ wait_duration: timedelta | None = None,
504
+ ) -> dict | None:
505
+ """Wait asynchronously for an Actor job (run or build) to finish.
506
+
507
+ Polls the job status until it reaches a terminal state or timeout.
508
+ Handles 404 errors gracefully (job might not exist yet in replicas).
509
+
510
+ Args:
511
+ url: Full URL to the job endpoint.
512
+ params: Base query parameters to include in each request.
513
+ timeout: Timeout for each individual HTTP request.
514
+ wait_duration: Maximum time to wait (None = indefinite).
515
+
516
+ Returns:
517
+ Job data dict when finished, or None if job doesn't exist after
518
+ DEFAULT_WAIT_WHEN_JOB_NOT_EXIST seconds.
519
+
520
+ Raises:
521
+ ApifyApiError: If API returns errors other than 404.
522
+ """
523
+ deadline = (datetime.now(UTC) + wait_duration) if wait_duration is not None else None
524
+ not_found_deadline: datetime | None = None
525
+ actor_job: dict = {}
526
+
527
+ while True:
528
+ if deadline is not None:
529
+ remaining_secs = max(0, int(to_seconds(deadline - datetime.now(UTC))))
530
+ wait_for_finish = remaining_secs
531
+ else:
532
+ wait_for_finish = to_seconds(DEFAULT_WAIT_FOR_FINISH, as_int=True)
533
+
534
+ try:
535
+ response = await self._http_client.call(
536
+ url=url,
537
+ method='GET',
538
+ params={**params, 'waitForFinish': wait_for_finish},
539
+ timeout=timeout,
540
+ )
541
+ result = response_to_dict(response)
542
+ actor_job = result.get('data') or {}
543
+
544
+ # Reset the not-found streak so a later transient 404 gets its own grace window.
545
+ not_found_deadline = None
546
+
547
+ is_terminal = actor_job.get('status') in _TERMINAL_STATUSES
548
+ is_timed_out = deadline is not None and datetime.now(UTC) >= deadline
549
+
550
+ if is_terminal or is_timed_out:
551
+ break
552
+
553
+ except ApifyApiError as exc:
554
+ catch_not_found_or_throw(exc)
555
+
556
+ now = datetime.now(UTC)
557
+ if deadline is not None and now >= deadline:
558
+ return None
559
+ if not_found_deadline is None:
560
+ not_found_deadline = now + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
561
+ elif now > not_found_deadline:
562
+ return None
563
+
564
+ # It might take some time for database replicas to get up-to-date so sleep a bit before retrying
565
+ await asyncio.sleep(0.25)
566
+
567
+ return actor_job