rayon-sdk-py 0.1.0__tar.gz
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.
- rayon_sdk_py-0.1.0/LICENSE +21 -0
- rayon_sdk_py-0.1.0/PKG-INFO +125 -0
- rayon_sdk_py-0.1.0/README.md +103 -0
- rayon_sdk_py-0.1.0/pyproject.toml +34 -0
- rayon_sdk_py-0.1.0/setup.cfg +4 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py/__init__.py +26 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py/client.py +78 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py/errors.py +46 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py/models.py +16 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py/validation.py +208 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py.egg-info/PKG-INFO +125 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py.egg-info/SOURCES.txt +14 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py.egg-info/dependency_links.txt +1 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py.egg-info/requires.txt +6 -0
- rayon_sdk_py-0.1.0/src/rayon_sdk_py.egg-info/top_level.txt +1 -0
- rayon_sdk_py-0.1.0/tests/test_sdk.py +481 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RayonAPI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rayon-sdk-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronous Python client for the Rayon API
|
|
5
|
+
Author: Rayon API
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://rayonapi.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Rayon-API/rayon-sdk-py
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: curl_cffi<1,>=0.13
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
20
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Rayon Python SDK
|
|
24
|
+
|
|
25
|
+
A synchronous client for `POST /solve` on the Rayon API. Python 3.10 or newer is required.
|
|
26
|
+
|
|
27
|
+
Install from PyPI:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
python -m pip install rayon-sdk-py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os
|
|
35
|
+
from rayon_sdk_py import solve
|
|
36
|
+
|
|
37
|
+
result = solve(
|
|
38
|
+
"https://example.com/page",
|
|
39
|
+
os.environ["RAYON_API_KEY"],
|
|
40
|
+
# Optional JSON fingerprint values:
|
|
41
|
+
profile={"user_agent": "your browser user agent"},
|
|
42
|
+
)
|
|
43
|
+
print(result.status, result.method, result.duration_ms)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`solve(target_url, api_key, *, profile=None, base_url="https://api.rayonapi.com", timeout=150)`
|
|
47
|
+
performs one request using curl_cffi Chrome impersonation. Timeout is a finite positive
|
|
48
|
+
number of seconds. The SDK disables redirects and environment proxies, and never retries.
|
|
49
|
+
Decoded response bodies are limited to 2 MiB; larger responses stop the transfer
|
|
50
|
+
and raise `InvalidResponseError`.
|
|
51
|
+
Custom API endpoints require HTTPS; HTTP is supported only for `localhost`, `127.0.0.1`,
|
|
52
|
+
or `[::1]` development endpoints. URLs cannot contain credentials or fragments;
|
|
53
|
+
the API base URL also cannot contain a query.
|
|
54
|
+
|
|
55
|
+
The request body contains only `target_url` and, when supplied, `profile`.
|
|
56
|
+
The API key is sent as a Bearer authorization header. Use an account and target
|
|
57
|
+
domain approved for your application.
|
|
58
|
+
|
|
59
|
+
An accepted result contains exactly one six-header family with a variable prefix
|
|
60
|
+
and the suffixes `a`, `b`, `c`, `d`, `f`, and `z`; `d` may be empty.
|
|
61
|
+
Acceptance means the headers were captured. It does not mean the target website
|
|
62
|
+
verified them. `result.errors` can contain diagnostics from earlier solver tiers
|
|
63
|
+
even when a fallback succeeds.
|
|
64
|
+
|
|
65
|
+
`SolveResult` exposes `status`, `method`, `target_url`, `duration_ms`, `headers`,
|
|
66
|
+
`cookies`, `profile_used`, and `errors`. Headers, cookies, URL, profile, and diagnostics
|
|
67
|
+
are excluded from its repr because replay data can be sensitive.
|
|
68
|
+
|
|
69
|
+
Cookies remain a list of records with required string fields `name`, `value`,
|
|
70
|
+
`domain`, and `path`. Extra JSON metadata is preserved. Cookie scope matters:
|
|
71
|
+
do not flatten cookies into a name/value dictionary. The SDK does not invent
|
|
72
|
+
`secure`, expiry, or other browser attributes absent from the API response.
|
|
73
|
+
When replaying a result, preserve cookie domains and paths and use the returned
|
|
74
|
+
`profile_used` values for a compatible browser fingerprint.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from curl_cffi.requests import Cookies
|
|
78
|
+
|
|
79
|
+
jar = Cookies()
|
|
80
|
+
for cookie in result.cookies:
|
|
81
|
+
jar.set(cookie["name"], cookie["value"], domain=cookie["domain"], path=cookie["path"])
|
|
82
|
+
# result.headers, jar, and result.profile_used are available for your replay client.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Handle failures explicitly:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import os
|
|
89
|
+
from rayon_sdk_py import HTTPError, RayonError, SolveFailedError, solve
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
result = solve("https://example.com/page", os.environ["RAYON_API_KEY"])
|
|
93
|
+
except SolveFailedError as exc:
|
|
94
|
+
# HTTP 200 with a failed/rejected result also raises.
|
|
95
|
+
print(exc.result.status)
|
|
96
|
+
except HTTPError as exc:
|
|
97
|
+
print(exc.status_code, exc.request_id, exc.retry_after)
|
|
98
|
+
except RayonError as exc:
|
|
99
|
+
print(str(exc))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
All SDK errors inherit from `RayonError`. `ValidationError` is raised before
|
|
103
|
+
network access; `TransportError` covers network failures; `RedirectError` is an
|
|
104
|
+
`HTTPError`; `InvalidResponseError` covers invalid JSON or malformed responses.
|
|
105
|
+
Exception messages do not include response bodies, credentials, or underlying
|
|
106
|
+
transport messages. HTTP response errors expose `status_code`, `request_id`, and
|
|
107
|
+
`retry_after` (a validated seconds or HTTP date header string, when present).
|
|
108
|
+
Invalid diagnostic headers or values reflecting the API key are discarded.
|
|
109
|
+
Decide whether and when
|
|
110
|
+
to retry in your application; admitted solve attempts can consume quota.
|
|
111
|
+
|
|
112
|
+
For local development:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
python -m venv .venv
|
|
116
|
+
. .venv/bin/activate
|
|
117
|
+
python -m pip install -e '.[dev]'
|
|
118
|
+
python -m pytest
|
|
119
|
+
python -m build
|
|
120
|
+
python -m twine check dist/*
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Tests are offline and patch the transport. CI tests Python 3.10, 3.12, and 3.14
|
|
124
|
+
and builds the wheel and source distribution. See [RELEASE.md](RELEASE.md) for
|
|
125
|
+
the trusted publisher setup. Licensed under [MIT](LICENSE).
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Rayon Python SDK
|
|
2
|
+
|
|
3
|
+
A synchronous client for `POST /solve` on the Rayon API. Python 3.10 or newer is required.
|
|
4
|
+
|
|
5
|
+
Install from PyPI:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
python -m pip install rayon-sdk-py
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import os
|
|
13
|
+
from rayon_sdk_py import solve
|
|
14
|
+
|
|
15
|
+
result = solve(
|
|
16
|
+
"https://example.com/page",
|
|
17
|
+
os.environ["RAYON_API_KEY"],
|
|
18
|
+
# Optional JSON fingerprint values:
|
|
19
|
+
profile={"user_agent": "your browser user agent"},
|
|
20
|
+
)
|
|
21
|
+
print(result.status, result.method, result.duration_ms)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`solve(target_url, api_key, *, profile=None, base_url="https://api.rayonapi.com", timeout=150)`
|
|
25
|
+
performs one request using curl_cffi Chrome impersonation. Timeout is a finite positive
|
|
26
|
+
number of seconds. The SDK disables redirects and environment proxies, and never retries.
|
|
27
|
+
Decoded response bodies are limited to 2 MiB; larger responses stop the transfer
|
|
28
|
+
and raise `InvalidResponseError`.
|
|
29
|
+
Custom API endpoints require HTTPS; HTTP is supported only for `localhost`, `127.0.0.1`,
|
|
30
|
+
or `[::1]` development endpoints. URLs cannot contain credentials or fragments;
|
|
31
|
+
the API base URL also cannot contain a query.
|
|
32
|
+
|
|
33
|
+
The request body contains only `target_url` and, when supplied, `profile`.
|
|
34
|
+
The API key is sent as a Bearer authorization header. Use an account and target
|
|
35
|
+
domain approved for your application.
|
|
36
|
+
|
|
37
|
+
An accepted result contains exactly one six-header family with a variable prefix
|
|
38
|
+
and the suffixes `a`, `b`, `c`, `d`, `f`, and `z`; `d` may be empty.
|
|
39
|
+
Acceptance means the headers were captured. It does not mean the target website
|
|
40
|
+
verified them. `result.errors` can contain diagnostics from earlier solver tiers
|
|
41
|
+
even when a fallback succeeds.
|
|
42
|
+
|
|
43
|
+
`SolveResult` exposes `status`, `method`, `target_url`, `duration_ms`, `headers`,
|
|
44
|
+
`cookies`, `profile_used`, and `errors`. Headers, cookies, URL, profile, and diagnostics
|
|
45
|
+
are excluded from its repr because replay data can be sensitive.
|
|
46
|
+
|
|
47
|
+
Cookies remain a list of records with required string fields `name`, `value`,
|
|
48
|
+
`domain`, and `path`. Extra JSON metadata is preserved. Cookie scope matters:
|
|
49
|
+
do not flatten cookies into a name/value dictionary. The SDK does not invent
|
|
50
|
+
`secure`, expiry, or other browser attributes absent from the API response.
|
|
51
|
+
When replaying a result, preserve cookie domains and paths and use the returned
|
|
52
|
+
`profile_used` values for a compatible browser fingerprint.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from curl_cffi.requests import Cookies
|
|
56
|
+
|
|
57
|
+
jar = Cookies()
|
|
58
|
+
for cookie in result.cookies:
|
|
59
|
+
jar.set(cookie["name"], cookie["value"], domain=cookie["domain"], path=cookie["path"])
|
|
60
|
+
# result.headers, jar, and result.profile_used are available for your replay client.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Handle failures explicitly:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import os
|
|
67
|
+
from rayon_sdk_py import HTTPError, RayonError, SolveFailedError, solve
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
result = solve("https://example.com/page", os.environ["RAYON_API_KEY"])
|
|
71
|
+
except SolveFailedError as exc:
|
|
72
|
+
# HTTP 200 with a failed/rejected result also raises.
|
|
73
|
+
print(exc.result.status)
|
|
74
|
+
except HTTPError as exc:
|
|
75
|
+
print(exc.status_code, exc.request_id, exc.retry_after)
|
|
76
|
+
except RayonError as exc:
|
|
77
|
+
print(str(exc))
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
All SDK errors inherit from `RayonError`. `ValidationError` is raised before
|
|
81
|
+
network access; `TransportError` covers network failures; `RedirectError` is an
|
|
82
|
+
`HTTPError`; `InvalidResponseError` covers invalid JSON or malformed responses.
|
|
83
|
+
Exception messages do not include response bodies, credentials, or underlying
|
|
84
|
+
transport messages. HTTP response errors expose `status_code`, `request_id`, and
|
|
85
|
+
`retry_after` (a validated seconds or HTTP date header string, when present).
|
|
86
|
+
Invalid diagnostic headers or values reflecting the API key are discarded.
|
|
87
|
+
Decide whether and when
|
|
88
|
+
to retry in your application; admitted solve attempts can consume quota.
|
|
89
|
+
|
|
90
|
+
For local development:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
python -m venv .venv
|
|
94
|
+
. .venv/bin/activate
|
|
95
|
+
python -m pip install -e '.[dev]'
|
|
96
|
+
python -m pytest
|
|
97
|
+
python -m build
|
|
98
|
+
python -m twine check dist/*
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Tests are offline and patch the transport. CI tests Python 3.10, 3.12, and 3.14
|
|
102
|
+
and builds the wheel and source distribution. See [RELEASE.md](RELEASE.md) for
|
|
103
|
+
the trusted publisher setup. Licensed under [MIT](LICENSE).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rayon-sdk-py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Synchronous Python client for the Rayon API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "Rayon API"}]
|
|
14
|
+
dependencies = ["curl_cffi>=0.13,<1"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=8", "build>=1.2", "twine>=6"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://rayonapi.com"
|
|
27
|
+
Repository = "https://github.com/Rayon-API/rayon-sdk-py"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Synchronous client for the public Rayon API."""
|
|
2
|
+
|
|
3
|
+
from .client import solve
|
|
4
|
+
from .models import SolveResult
|
|
5
|
+
from .errors import (
|
|
6
|
+
HTTPError,
|
|
7
|
+
InvalidResponseError,
|
|
8
|
+
RayonError,
|
|
9
|
+
RedirectError,
|
|
10
|
+
SolveFailedError,
|
|
11
|
+
TransportError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"solve",
|
|
18
|
+
"SolveResult",
|
|
19
|
+
"RayonError",
|
|
20
|
+
"ValidationError",
|
|
21
|
+
"TransportError",
|
|
22
|
+
"HTTPError",
|
|
23
|
+
"RedirectError",
|
|
24
|
+
"InvalidResponseError",
|
|
25
|
+
"SolveFailedError",
|
|
26
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from curl_cffi import CurlOpt, requests
|
|
2
|
+
from curl_cffi.curl import CURL_WRITEFUNC_ERROR
|
|
3
|
+
|
|
4
|
+
from .errors import (
|
|
5
|
+
HTTPError,
|
|
6
|
+
InvalidResponseError,
|
|
7
|
+
RedirectError,
|
|
8
|
+
SolveFailedError,
|
|
9
|
+
TransportError,
|
|
10
|
+
)
|
|
11
|
+
from .models import SolveResult
|
|
12
|
+
from .validation import parse_result, response_metadata, validate_request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def solve(
|
|
19
|
+
target_url: str,
|
|
20
|
+
api_key: str,
|
|
21
|
+
*,
|
|
22
|
+
profile: dict | None = None,
|
|
23
|
+
base_url: str = "https://api.rayonapi.com",
|
|
24
|
+
timeout: float = 150,
|
|
25
|
+
) -> SolveResult:
|
|
26
|
+
"""Capture a telemetry header family with one synchronous API request."""
|
|
27
|
+
profile = validate_request(target_url, api_key, profile, base_url, timeout)
|
|
28
|
+
body = {"target_url": target_url}
|
|
29
|
+
if profile is not None:
|
|
30
|
+
body["profile"] = profile
|
|
31
|
+
content = bytearray()
|
|
32
|
+
oversized = False
|
|
33
|
+
|
|
34
|
+
def collect(chunk):
|
|
35
|
+
nonlocal oversized
|
|
36
|
+
if len(content) + len(chunk) > _MAX_RESPONSE_BYTES:
|
|
37
|
+
oversized = True
|
|
38
|
+
return CURL_WRITEFUNC_ERROR
|
|
39
|
+
content.extend(chunk)
|
|
40
|
+
return len(chunk)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Some curl_cffi versions leave libcurl's environment proxy lookup enabled.
|
|
44
|
+
with requests.Session(
|
|
45
|
+
impersonate="chrome", trust_env=False, curl_options={CurlOpt.PROXY: ""}
|
|
46
|
+
) as session:
|
|
47
|
+
response = session.post(
|
|
48
|
+
base_url.rstrip("/") + "/solve",
|
|
49
|
+
json=body,
|
|
50
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
allow_redirects=False,
|
|
53
|
+
content_callback=collect,
|
|
54
|
+
)
|
|
55
|
+
except requests.exceptions.RequestException:
|
|
56
|
+
if oversized:
|
|
57
|
+
raise InvalidResponseError(
|
|
58
|
+
"Rayon API response exceeded the 2 MiB limit."
|
|
59
|
+
) from None
|
|
60
|
+
raise TransportError("Rayon API request could not be completed.") from None
|
|
61
|
+
response.content = bytes(content)
|
|
62
|
+
metadata = response_metadata(response, api_key)
|
|
63
|
+
if 300 <= response.status_code < 400:
|
|
64
|
+
raise RedirectError(
|
|
65
|
+
"Rayon API returned a redirect; no redirect was followed.", **metadata
|
|
66
|
+
)
|
|
67
|
+
if response.status_code != 200:
|
|
68
|
+
raise HTTPError("Rayon API returned an unsuccessful HTTP status.", **metadata)
|
|
69
|
+
try:
|
|
70
|
+
body = response.json()
|
|
71
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
72
|
+
raise InvalidResponseError(
|
|
73
|
+
"Rayon API returned invalid JSON.", **metadata
|
|
74
|
+
) from None
|
|
75
|
+
result = parse_result(body, target_url, metadata)
|
|
76
|
+
if result.status != "accepted":
|
|
77
|
+
raise SolveFailedError(result, **metadata)
|
|
78
|
+
return result
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from .models import SolveResult
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RayonError(Exception):
|
|
5
|
+
"""Base class for all SDK failures; messages never include API response bodies."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status_code: int | None = None,
|
|
12
|
+
request_id: str | None = None,
|
|
13
|
+
retry_after: str | None = None,
|
|
14
|
+
):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.request_id = request_id
|
|
18
|
+
self.retry_after = retry_after
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ValidationError(RayonError, ValueError):
|
|
22
|
+
"""A request argument is invalid; no API request was sent."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransportError(RayonError):
|
|
26
|
+
"""The API request could not be completed."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HTTPError(RayonError):
|
|
30
|
+
"""The API returned an unsuccessful HTTP status."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RedirectError(HTTPError):
|
|
34
|
+
"""The API returned a redirect, which the SDK did not follow."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InvalidResponseError(RayonError):
|
|
38
|
+
"""The API response was not valid JSON or did not match the contract."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SolveFailedError(RayonError):
|
|
42
|
+
"""The API returned a valid failed or rejected solve result."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, result: SolveResult, **metadata):
|
|
45
|
+
super().__init__("Rayon solve was not accepted.", **metadata)
|
|
46
|
+
self.result = result
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class SolveResult:
|
|
7
|
+
"""Captured telemetry and the metadata needed to replay it."""
|
|
8
|
+
|
|
9
|
+
status: str
|
|
10
|
+
method: str
|
|
11
|
+
target_url: str = field(repr=False)
|
|
12
|
+
duration_ms: int
|
|
13
|
+
headers: dict[str, str] = field(repr=False)
|
|
14
|
+
cookies: list[dict[str, Any]] = field(repr=False)
|
|
15
|
+
profile_used: dict[str, Any] = field(repr=False)
|
|
16
|
+
errors: list[str] = field(repr=False)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
from email.utils import format_datetime, parsedate_to_datetime
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from .errors import InvalidResponseError, ValidationError
|
|
8
|
+
from .models import SolveResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def response_metadata(response, api_key):
|
|
12
|
+
request_id = response.headers.get("X-Request-ID")
|
|
13
|
+
if (
|
|
14
|
+
not isinstance(request_id, str)
|
|
15
|
+
or api_key in request_id
|
|
16
|
+
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", request_id)
|
|
17
|
+
):
|
|
18
|
+
request_id = None
|
|
19
|
+
retry_after = response.headers.get("Retry-After")
|
|
20
|
+
if (
|
|
21
|
+
not isinstance(retry_after, str)
|
|
22
|
+
or api_key in retry_after
|
|
23
|
+
or len(retry_after) > 128
|
|
24
|
+
):
|
|
25
|
+
retry_after = None
|
|
26
|
+
elif not re.fullmatch(r"[0-9]{1,10}", retry_after):
|
|
27
|
+
try:
|
|
28
|
+
parsed = parsedate_to_datetime(retry_after)
|
|
29
|
+
if format_datetime(parsed, usegmt=True) != retry_after:
|
|
30
|
+
retry_after = None
|
|
31
|
+
except (ValueError, TypeError, OverflowError):
|
|
32
|
+
retry_after = None
|
|
33
|
+
return {
|
|
34
|
+
"status_code": response.status_code,
|
|
35
|
+
"request_id": request_id,
|
|
36
|
+
"retry_after": retry_after,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _json_value(value, seen=None):
|
|
41
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
42
|
+
return True
|
|
43
|
+
if isinstance(value, float):
|
|
44
|
+
return math.isfinite(value)
|
|
45
|
+
if not isinstance(value, (dict, list)):
|
|
46
|
+
return False
|
|
47
|
+
seen = set() if seen is None else seen
|
|
48
|
+
if id(value) in seen:
|
|
49
|
+
return False
|
|
50
|
+
seen.add(id(value))
|
|
51
|
+
try:
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
return all(
|
|
54
|
+
isinstance(k, str) and _json_value(v, seen) for k, v in value.items()
|
|
55
|
+
)
|
|
56
|
+
return all(_json_value(item, seen) for item in value)
|
|
57
|
+
finally:
|
|
58
|
+
seen.remove(id(value))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _valid_url(value: str, *, base=False):
|
|
62
|
+
if not isinstance(value, str) or not value or len(value) > 8192:
|
|
63
|
+
return False
|
|
64
|
+
if any(ord(char) <= 32 or ord(char) == 127 for char in value) or "\\" in value:
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
parts = urlsplit(value)
|
|
68
|
+
port = parts.port
|
|
69
|
+
if (
|
|
70
|
+
parts.scheme not in {"http", "https"}
|
|
71
|
+
or not parts.hostname
|
|
72
|
+
or parts.username is not None
|
|
73
|
+
or parts.password is not None
|
|
74
|
+
or "#" in value
|
|
75
|
+
):
|
|
76
|
+
return False
|
|
77
|
+
if base and (
|
|
78
|
+
"?" in value
|
|
79
|
+
or (
|
|
80
|
+
parts.scheme == "http"
|
|
81
|
+
and parts.hostname not in {"localhost", "127.0.0.1", "::1"}
|
|
82
|
+
)
|
|
83
|
+
):
|
|
84
|
+
return False
|
|
85
|
+
return port is None or 0 < port <= 65535
|
|
86
|
+
except ValueError:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_request(target_url, api_key, profile, base_url, timeout):
|
|
91
|
+
if not _valid_url(target_url):
|
|
92
|
+
raise ValidationError(
|
|
93
|
+
"target_url must be an HTTP(S) URL without credentials or a fragment."
|
|
94
|
+
)
|
|
95
|
+
if (
|
|
96
|
+
not isinstance(api_key, str)
|
|
97
|
+
or not api_key
|
|
98
|
+
or any(not 33 <= ord(char) <= 126 for char in api_key)
|
|
99
|
+
):
|
|
100
|
+
raise ValidationError(
|
|
101
|
+
"api_key must be a nonempty ASCII token without whitespace."
|
|
102
|
+
)
|
|
103
|
+
if not _valid_url(base_url, base=True):
|
|
104
|
+
raise ValidationError(
|
|
105
|
+
"base_url must be HTTPS (or local HTTP) without credentials, query, or fragment."
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
valid_timeout = (
|
|
109
|
+
isinstance(timeout, (int, float))
|
|
110
|
+
and not isinstance(timeout, bool)
|
|
111
|
+
and math.isfinite(timeout)
|
|
112
|
+
and timeout > 0
|
|
113
|
+
)
|
|
114
|
+
except OverflowError:
|
|
115
|
+
valid_timeout = False
|
|
116
|
+
if not valid_timeout:
|
|
117
|
+
raise ValidationError("timeout must be a finite positive number of seconds.")
|
|
118
|
+
try:
|
|
119
|
+
if profile is not None and (
|
|
120
|
+
not isinstance(profile, dict)
|
|
121
|
+
or not _json_value(profile)
|
|
122
|
+
or ("profile" in profile and not isinstance(profile["profile"], dict))
|
|
123
|
+
):
|
|
124
|
+
raise ValueError
|
|
125
|
+
return (
|
|
126
|
+
None
|
|
127
|
+
if profile is None
|
|
128
|
+
else json.loads(json.dumps(profile, allow_nan=False))
|
|
129
|
+
)
|
|
130
|
+
except (ValueError, TypeError, RecursionError, OverflowError):
|
|
131
|
+
raise ValidationError(
|
|
132
|
+
"profile must be a JSON object with finite values and string keys."
|
|
133
|
+
) from None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_result(body, target_url, metadata):
|
|
137
|
+
required = {
|
|
138
|
+
"status",
|
|
139
|
+
"method",
|
|
140
|
+
"target_url",
|
|
141
|
+
"duration_ms",
|
|
142
|
+
"headers",
|
|
143
|
+
"cookies",
|
|
144
|
+
"profile_used",
|
|
145
|
+
"errors",
|
|
146
|
+
}
|
|
147
|
+
valid = isinstance(body, dict) and required <= body.keys()
|
|
148
|
+
if valid:
|
|
149
|
+
valid = (
|
|
150
|
+
body["status"] in ("accepted", "failed", "rejected")
|
|
151
|
+
and body["method"] in ("v8_sandbox", "playwright")
|
|
152
|
+
and body["target_url"] == target_url
|
|
153
|
+
and type(body["duration_ms"]) is int
|
|
154
|
+
and body["duration_ms"] >= 0
|
|
155
|
+
and isinstance(body["headers"], dict)
|
|
156
|
+
and isinstance(body["cookies"], list)
|
|
157
|
+
and isinstance(body["profile_used"], dict)
|
|
158
|
+
and isinstance(body["errors"], list)
|
|
159
|
+
and all(isinstance(error, str) for error in body["errors"])
|
|
160
|
+
)
|
|
161
|
+
if valid:
|
|
162
|
+
try:
|
|
163
|
+
valid = _json_value(body["profile_used"]) and all(
|
|
164
|
+
isinstance(cookie, dict)
|
|
165
|
+
and {"name", "value", "domain", "path"} <= cookie.keys()
|
|
166
|
+
and all(
|
|
167
|
+
isinstance(cookie[field], str)
|
|
168
|
+
for field in ("name", "value", "domain", "path")
|
|
169
|
+
)
|
|
170
|
+
and _json_value(cookie)
|
|
171
|
+
for cookie in body["cookies"]
|
|
172
|
+
)
|
|
173
|
+
except RecursionError:
|
|
174
|
+
valid = False
|
|
175
|
+
if valid:
|
|
176
|
+
headers = body["headers"]
|
|
177
|
+
valid = all(
|
|
178
|
+
isinstance(name, str)
|
|
179
|
+
and name.isascii()
|
|
180
|
+
and isinstance(value, str)
|
|
181
|
+
and not any(
|
|
182
|
+
ord(char) < 32 or ord(char) == 127 or ord(char) > 255 for char in value
|
|
183
|
+
)
|
|
184
|
+
for name, value in headers.items()
|
|
185
|
+
)
|
|
186
|
+
if valid and body["status"] == "accepted":
|
|
187
|
+
families = []
|
|
188
|
+
suffixes = []
|
|
189
|
+
for name, value in headers.items():
|
|
190
|
+
match = re.fullmatch(
|
|
191
|
+
r"([!#$%&'*+\-.^_`|~0-9A-Za-z]+)-([abcdfz])", name, re.IGNORECASE
|
|
192
|
+
)
|
|
193
|
+
if not match or (match[2].lower() != "d" and not value.strip()):
|
|
194
|
+
valid = False
|
|
195
|
+
break
|
|
196
|
+
families.append(match[1].lower())
|
|
197
|
+
suffixes.append(match[2].lower())
|
|
198
|
+
valid = (
|
|
199
|
+
valid
|
|
200
|
+
and len(headers) == 6
|
|
201
|
+
and len(set(families)) == 1
|
|
202
|
+
and set(suffixes) == set("abcdfz")
|
|
203
|
+
)
|
|
204
|
+
if not valid:
|
|
205
|
+
raise InvalidResponseError(
|
|
206
|
+
"Rayon API returned an invalid solve response.", **metadata
|
|
207
|
+
)
|
|
208
|
+
return SolveResult(**{field: body[field] for field in required})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rayon-sdk-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronous Python client for the Rayon API
|
|
5
|
+
Author: Rayon API
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://rayonapi.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Rayon-API/rayon-sdk-py
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: curl_cffi<1,>=0.13
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
20
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Rayon Python SDK
|
|
24
|
+
|
|
25
|
+
A synchronous client for `POST /solve` on the Rayon API. Python 3.10 or newer is required.
|
|
26
|
+
|
|
27
|
+
Install from PyPI:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
python -m pip install rayon-sdk-py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os
|
|
35
|
+
from rayon_sdk_py import solve
|
|
36
|
+
|
|
37
|
+
result = solve(
|
|
38
|
+
"https://example.com/page",
|
|
39
|
+
os.environ["RAYON_API_KEY"],
|
|
40
|
+
# Optional JSON fingerprint values:
|
|
41
|
+
profile={"user_agent": "your browser user agent"},
|
|
42
|
+
)
|
|
43
|
+
print(result.status, result.method, result.duration_ms)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`solve(target_url, api_key, *, profile=None, base_url="https://api.rayonapi.com", timeout=150)`
|
|
47
|
+
performs one request using curl_cffi Chrome impersonation. Timeout is a finite positive
|
|
48
|
+
number of seconds. The SDK disables redirects and environment proxies, and never retries.
|
|
49
|
+
Decoded response bodies are limited to 2 MiB; larger responses stop the transfer
|
|
50
|
+
and raise `InvalidResponseError`.
|
|
51
|
+
Custom API endpoints require HTTPS; HTTP is supported only for `localhost`, `127.0.0.1`,
|
|
52
|
+
or `[::1]` development endpoints. URLs cannot contain credentials or fragments;
|
|
53
|
+
the API base URL also cannot contain a query.
|
|
54
|
+
|
|
55
|
+
The request body contains only `target_url` and, when supplied, `profile`.
|
|
56
|
+
The API key is sent as a Bearer authorization header. Use an account and target
|
|
57
|
+
domain approved for your application.
|
|
58
|
+
|
|
59
|
+
An accepted result contains exactly one six-header family with a variable prefix
|
|
60
|
+
and the suffixes `a`, `b`, `c`, `d`, `f`, and `z`; `d` may be empty.
|
|
61
|
+
Acceptance means the headers were captured. It does not mean the target website
|
|
62
|
+
verified them. `result.errors` can contain diagnostics from earlier solver tiers
|
|
63
|
+
even when a fallback succeeds.
|
|
64
|
+
|
|
65
|
+
`SolveResult` exposes `status`, `method`, `target_url`, `duration_ms`, `headers`,
|
|
66
|
+
`cookies`, `profile_used`, and `errors`. Headers, cookies, URL, profile, and diagnostics
|
|
67
|
+
are excluded from its repr because replay data can be sensitive.
|
|
68
|
+
|
|
69
|
+
Cookies remain a list of records with required string fields `name`, `value`,
|
|
70
|
+
`domain`, and `path`. Extra JSON metadata is preserved. Cookie scope matters:
|
|
71
|
+
do not flatten cookies into a name/value dictionary. The SDK does not invent
|
|
72
|
+
`secure`, expiry, or other browser attributes absent from the API response.
|
|
73
|
+
When replaying a result, preserve cookie domains and paths and use the returned
|
|
74
|
+
`profile_used` values for a compatible browser fingerprint.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from curl_cffi.requests import Cookies
|
|
78
|
+
|
|
79
|
+
jar = Cookies()
|
|
80
|
+
for cookie in result.cookies:
|
|
81
|
+
jar.set(cookie["name"], cookie["value"], domain=cookie["domain"], path=cookie["path"])
|
|
82
|
+
# result.headers, jar, and result.profile_used are available for your replay client.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Handle failures explicitly:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import os
|
|
89
|
+
from rayon_sdk_py import HTTPError, RayonError, SolveFailedError, solve
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
result = solve("https://example.com/page", os.environ["RAYON_API_KEY"])
|
|
93
|
+
except SolveFailedError as exc:
|
|
94
|
+
# HTTP 200 with a failed/rejected result also raises.
|
|
95
|
+
print(exc.result.status)
|
|
96
|
+
except HTTPError as exc:
|
|
97
|
+
print(exc.status_code, exc.request_id, exc.retry_after)
|
|
98
|
+
except RayonError as exc:
|
|
99
|
+
print(str(exc))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
All SDK errors inherit from `RayonError`. `ValidationError` is raised before
|
|
103
|
+
network access; `TransportError` covers network failures; `RedirectError` is an
|
|
104
|
+
`HTTPError`; `InvalidResponseError` covers invalid JSON or malformed responses.
|
|
105
|
+
Exception messages do not include response bodies, credentials, or underlying
|
|
106
|
+
transport messages. HTTP response errors expose `status_code`, `request_id`, and
|
|
107
|
+
`retry_after` (a validated seconds or HTTP date header string, when present).
|
|
108
|
+
Invalid diagnostic headers or values reflecting the API key are discarded.
|
|
109
|
+
Decide whether and when
|
|
110
|
+
to retry in your application; admitted solve attempts can consume quota.
|
|
111
|
+
|
|
112
|
+
For local development:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
python -m venv .venv
|
|
116
|
+
. .venv/bin/activate
|
|
117
|
+
python -m pip install -e '.[dev]'
|
|
118
|
+
python -m pytest
|
|
119
|
+
python -m build
|
|
120
|
+
python -m twine check dist/*
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Tests are offline and patch the transport. CI tests Python 3.10, 3.12, and 3.14
|
|
124
|
+
and builds the wheel and source distribution. See [RELEASE.md](RELEASE.md) for
|
|
125
|
+
the trusted publisher setup. Licensed under [MIT](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/rayon_sdk_py/__init__.py
|
|
5
|
+
src/rayon_sdk_py/client.py
|
|
6
|
+
src/rayon_sdk_py/errors.py
|
|
7
|
+
src/rayon_sdk_py/models.py
|
|
8
|
+
src/rayon_sdk_py/validation.py
|
|
9
|
+
src/rayon_sdk_py.egg-info/PKG-INFO
|
|
10
|
+
src/rayon_sdk_py.egg-info/SOURCES.txt
|
|
11
|
+
src/rayon_sdk_py.egg-info/dependency_links.txt
|
|
12
|
+
src/rayon_sdk_py.egg-info/requires.txt
|
|
13
|
+
src/rayon_sdk_py.egg-info/top_level.txt
|
|
14
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rayon_sdk_py
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from curl_cffi import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
TARGET = "https://example.com/page"
|
|
8
|
+
KEY = "rayon_test_secret"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def accepted(**changes):
|
|
12
|
+
body = {
|
|
13
|
+
"status": "accepted",
|
|
14
|
+
"method": "playwright",
|
|
15
|
+
"target_url": TARGET,
|
|
16
|
+
"duration_ms": 123,
|
|
17
|
+
"headers": {
|
|
18
|
+
f"X-RandomPrefix-{suffix}": "" if suffix == "d" else f"secret-{suffix}"
|
|
19
|
+
for suffix in "abcdfz"
|
|
20
|
+
},
|
|
21
|
+
"cookies": [
|
|
22
|
+
{
|
|
23
|
+
"name": "session",
|
|
24
|
+
"value": "cookie-secret",
|
|
25
|
+
"domain": ".example.com",
|
|
26
|
+
"path": "/page",
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"profile_used": {"user_agent": "profile-secret"},
|
|
30
|
+
"errors": ["Earlier tier failed"],
|
|
31
|
+
}
|
|
32
|
+
body.update(changes)
|
|
33
|
+
return body
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Response:
|
|
37
|
+
def __init__(self, body=None, status=200, headers=None):
|
|
38
|
+
self.body = accepted() if body is None else body
|
|
39
|
+
self.status_code = status
|
|
40
|
+
self.headers = requests.Headers(headers or {})
|
|
41
|
+
|
|
42
|
+
def json(self):
|
|
43
|
+
if isinstance(self.body, Exception):
|
|
44
|
+
raise self.body
|
|
45
|
+
return self.body
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def sdk():
|
|
50
|
+
try:
|
|
51
|
+
return importlib.import_module("rayon_sdk_py")
|
|
52
|
+
except ModuleNotFoundError:
|
|
53
|
+
pytest.fail("The public SDK module is not implemented")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def transport(monkeypatch):
|
|
58
|
+
state = {"response": Response(), "calls": [], "session_options": []}
|
|
59
|
+
|
|
60
|
+
class Session:
|
|
61
|
+
def __init__(self, **options):
|
|
62
|
+
state["session_options"].append(options)
|
|
63
|
+
|
|
64
|
+
def __enter__(self):
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def __exit__(self, *args):
|
|
68
|
+
state["closed"] = True
|
|
69
|
+
|
|
70
|
+
def post(self, url, **options):
|
|
71
|
+
state["calls"].append((url, options))
|
|
72
|
+
if "exception" in state:
|
|
73
|
+
raise state["exception"]
|
|
74
|
+
for chunk in state.get("chunks", []):
|
|
75
|
+
callback = options.get("content_callback")
|
|
76
|
+
if callback is not None and callback(chunk) != len(chunk):
|
|
77
|
+
state["aborted"] = True
|
|
78
|
+
raise requests.exceptions.RequestException("Body callback aborted")
|
|
79
|
+
return state["response"]
|
|
80
|
+
|
|
81
|
+
monkeypatch.setattr(requests, "Session", Session)
|
|
82
|
+
return state
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_solve_sends_only_public_body_and_returns_accepted_fallback(transport):
|
|
86
|
+
try:
|
|
87
|
+
sdk = importlib.import_module("rayon_sdk_py")
|
|
88
|
+
except ModuleNotFoundError:
|
|
89
|
+
pytest.fail("The public SDK module is not implemented")
|
|
90
|
+
profile = {"user_agent": "Chrome"}
|
|
91
|
+
result = sdk.solve(TARGET, KEY, profile=profile)
|
|
92
|
+
assert isinstance(result, sdk.SolveResult)
|
|
93
|
+
assert result.status == "accepted"
|
|
94
|
+
assert result.errors == ["Earlier tier failed"]
|
|
95
|
+
assert result.headers["X-RandomPrefix-d"] == ""
|
|
96
|
+
assert result.cookies == accepted()["cookies"]
|
|
97
|
+
assert result.profile_used == accepted()["profile_used"]
|
|
98
|
+
url, options = transport["calls"][0]
|
|
99
|
+
assert url == "https://api.rayonapi.com/solve"
|
|
100
|
+
assert options["json"] == {"target_url": TARGET, "profile": profile}
|
|
101
|
+
assert options["headers"]["Authorization"] == f"Bearer {KEY}"
|
|
102
|
+
assert options["timeout"] == 150
|
|
103
|
+
assert options["allow_redirects"] is False
|
|
104
|
+
assert transport["session_options"][0]["trust_env"] is False
|
|
105
|
+
assert transport["session_options"][0]["impersonate"] == "chrome"
|
|
106
|
+
assert transport["closed"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_defaults_omit_profile_and_custom_base_is_supported(sdk, transport):
|
|
110
|
+
sdk.solve(TARGET, KEY, base_url="http://localhost:8080/api/", timeout=2.5)
|
|
111
|
+
url, options = transport["calls"][0]
|
|
112
|
+
assert url == "http://localhost:8080/api/solve"
|
|
113
|
+
assert options["json"] == {"target_url": TARGET}
|
|
114
|
+
assert options["timeout"] == 2.5
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_result_repr_hides_replay_credentials(sdk, transport):
|
|
118
|
+
result = sdk.solve(TARGET, KEY)
|
|
119
|
+
assert all(
|
|
120
|
+
secret not in repr(result)
|
|
121
|
+
for secret in (KEY, "cookie-secret", "profile-secret", "secret-a")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.parametrize(
|
|
126
|
+
"target",
|
|
127
|
+
[
|
|
128
|
+
None,
|
|
129
|
+
"",
|
|
130
|
+
"ftp://example.com",
|
|
131
|
+
"https:///page",
|
|
132
|
+
"https://u:p@example.com",
|
|
133
|
+
"https://example.com\n",
|
|
134
|
+
"https://example.com:bad",
|
|
135
|
+
"https://example.com/#fragment",
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
def test_invalid_target_never_reaches_network(sdk, transport, target):
|
|
139
|
+
with pytest.raises(sdk.ValidationError):
|
|
140
|
+
sdk.solve(target, KEY)
|
|
141
|
+
assert not transport["calls"]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.parametrize(
|
|
145
|
+
"key", [None, "", " ", "key\r\nx: value", "key with space", "é"]
|
|
146
|
+
)
|
|
147
|
+
def test_invalid_api_key_never_reaches_network(sdk, transport, key):
|
|
148
|
+
with pytest.raises(sdk.ValidationError):
|
|
149
|
+
sdk.solve(TARGET, key)
|
|
150
|
+
assert not transport["calls"]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.parametrize(
|
|
154
|
+
"timeout", [0, -1, float("inf"), float("nan"), None, True, "10"]
|
|
155
|
+
)
|
|
156
|
+
def test_invalid_timeout_never_reaches_network(sdk, transport, timeout):
|
|
157
|
+
with pytest.raises(sdk.ValidationError):
|
|
158
|
+
sdk.solve(TARGET, KEY, timeout=timeout)
|
|
159
|
+
assert not transport["calls"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.parametrize(
|
|
163
|
+
"base",
|
|
164
|
+
[
|
|
165
|
+
None,
|
|
166
|
+
"",
|
|
167
|
+
"ftp://example.com",
|
|
168
|
+
"http://example.com",
|
|
169
|
+
"https://u:p@example.com",
|
|
170
|
+
"https://example.com?key=secret",
|
|
171
|
+
"https://example.com#fragment",
|
|
172
|
+
"https://example.com\t",
|
|
173
|
+
"https://example.com:bad",
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
def test_invalid_base_url_never_reaches_network(sdk, transport, base):
|
|
177
|
+
with pytest.raises(sdk.ValidationError):
|
|
178
|
+
sdk.solve(TARGET, KEY, base_url=base)
|
|
179
|
+
assert not transport["calls"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@pytest.mark.parametrize(
|
|
183
|
+
"profile",
|
|
184
|
+
[
|
|
185
|
+
[],
|
|
186
|
+
{"bad": object()},
|
|
187
|
+
{"bad": float("nan")},
|
|
188
|
+
{"profile": "bad"},
|
|
189
|
+
{1: "bad"},
|
|
190
|
+
{"nested": {1: "bad"}},
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
def test_invalid_profile_never_reaches_network(sdk, transport, profile):
|
|
194
|
+
with pytest.raises(sdk.ValidationError):
|
|
195
|
+
sdk.solve(TARGET, KEY, profile=profile)
|
|
196
|
+
assert not transport["calls"]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.mark.parametrize("status", [401, 403, 422, 429, 503])
|
|
200
|
+
def test_http_errors_are_safe_and_expose_diagnostics(sdk, transport, status):
|
|
201
|
+
transport["response"] = Response(
|
|
202
|
+
{"secret": KEY}, status, {"X-Request-ID": "req-123", "Retry-After": "30"}
|
|
203
|
+
)
|
|
204
|
+
with pytest.raises(sdk.HTTPError) as caught:
|
|
205
|
+
sdk.solve(TARGET, KEY)
|
|
206
|
+
error = caught.value
|
|
207
|
+
assert error.status_code == status
|
|
208
|
+
assert error.request_id == "req-123"
|
|
209
|
+
assert error.retry_after == "30"
|
|
210
|
+
assert KEY not in str(error) + repr(error)
|
|
211
|
+
assert len(transport["calls"]) == 1
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@pytest.mark.parametrize("status", [301, 302, 307, 308])
|
|
215
|
+
def test_redirects_are_typed_and_not_followed(sdk, transport, status):
|
|
216
|
+
transport["response"] = Response(
|
|
217
|
+
status=status, headers={"Location": f"https://example.com/{KEY}"}
|
|
218
|
+
)
|
|
219
|
+
with pytest.raises(sdk.RedirectError) as caught:
|
|
220
|
+
sdk.solve(TARGET, KEY)
|
|
221
|
+
assert caught.value.status_code == status
|
|
222
|
+
assert KEY not in str(caught.value)
|
|
223
|
+
assert len(transport["calls"]) == 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_network_failure_is_typed_safe_and_not_retried(sdk, transport):
|
|
227
|
+
transport["exception"] = requests.exceptions.RequestException(
|
|
228
|
+
f"Authorization: {KEY}"
|
|
229
|
+
)
|
|
230
|
+
with pytest.raises(sdk.TransportError) as caught:
|
|
231
|
+
sdk.solve(TARGET, KEY)
|
|
232
|
+
assert KEY not in str(caught.value) + repr(caught.value)
|
|
233
|
+
assert caught.value.__cause__ is None
|
|
234
|
+
assert len(transport["calls"]) == 1
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.mark.parametrize("failure", [ValueError, UnicodeError, RecursionError])
|
|
238
|
+
def test_invalid_json_is_typed_safe(sdk, transport, failure):
|
|
239
|
+
transport["response"] = Response(failure(KEY))
|
|
240
|
+
with pytest.raises(sdk.InvalidResponseError) as caught:
|
|
241
|
+
sdk.solve(TARGET, KEY)
|
|
242
|
+
assert KEY not in str(caught.value)
|
|
243
|
+
assert caught.value.__cause__ is None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_cookie_scope_and_extra_metadata_are_preserved(sdk, transport):
|
|
247
|
+
cookies = [
|
|
248
|
+
{"name": "session", "value": "first", "domain": "example.com", "path": "/"},
|
|
249
|
+
{
|
|
250
|
+
"name": "session",
|
|
251
|
+
"value": "second",
|
|
252
|
+
"domain": ".example.com",
|
|
253
|
+
"path": "/page",
|
|
254
|
+
"future": {"flag": True},
|
|
255
|
+
},
|
|
256
|
+
]
|
|
257
|
+
transport["response"] = Response(accepted(cookies=cookies))
|
|
258
|
+
result = sdk.solve(TARGET, KEY)
|
|
259
|
+
assert result.cookies == cookies
|
|
260
|
+
assert all(
|
|
261
|
+
"secure" not in cookie and "expiry" not in cookie for cookie in result.cookies
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_header_family_is_case_insensitive_and_prefix_is_variable(sdk, transport):
|
|
266
|
+
headers = {
|
|
267
|
+
f"Telemetry_9-{suffix}": "" if suffix == "D" else "value" for suffix in "ABCDfZ"
|
|
268
|
+
}
|
|
269
|
+
transport["response"] = Response(accepted(headers=headers))
|
|
270
|
+
assert sdk.solve(TARGET, KEY).headers == headers
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.mark.parametrize("value", ["value\u0100", "value\u20ac", "value\U0001f680"])
|
|
274
|
+
def test_response_headers_reject_characters_outside_latin1(sdk, transport, value):
|
|
275
|
+
headers = {**accepted()["headers"], "X-RandomPrefix-a": value}
|
|
276
|
+
transport["response"] = Response(accepted(headers=headers))
|
|
277
|
+
with pytest.raises(sdk.InvalidResponseError):
|
|
278
|
+
sdk.solve(TARGET, KEY)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_response_headers_allow_latin1_values(sdk, transport):
|
|
282
|
+
headers = {**accepted()["headers"], "X-RandomPrefix-a": "value\u00e9"}
|
|
283
|
+
transport["response"] = Response(accepted(headers=headers))
|
|
284
|
+
assert sdk.solve(TARGET, KEY).headers == headers
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@pytest.mark.parametrize("prefix", ["X-\u212a", "X-\u0130"])
|
|
288
|
+
def test_response_header_names_reject_unicode_casefold_matches(sdk, transport, prefix):
|
|
289
|
+
headers = {f"{prefix}-{suffix}": "value" for suffix in "abcdfz"}
|
|
290
|
+
transport["response"] = Response(accepted(headers=headers))
|
|
291
|
+
with pytest.raises(sdk.InvalidResponseError):
|
|
292
|
+
sdk.solve(TARGET, KEY)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_header_family_has_no_arbitrary_field_length_limits(sdk, transport):
|
|
296
|
+
prefix = "X-" + "P" * 300
|
|
297
|
+
headers = {f"{prefix}-{suffix}": "value" * 4000 for suffix in "abcdfz"}
|
|
298
|
+
transport["response"] = Response(accepted(headers=headers))
|
|
299
|
+
assert sdk.solve(TARGET, KEY).headers == headers
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_cycle_in_profile_is_a_validation_error(sdk, transport):
|
|
303
|
+
profile = {}
|
|
304
|
+
profile["nested"] = profile
|
|
305
|
+
with pytest.raises(sdk.ValidationError):
|
|
306
|
+
sdk.solve(TARGET, KEY, profile=profile)
|
|
307
|
+
assert not transport["calls"]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@pytest.mark.parametrize("status", ["failed", "rejected"])
|
|
311
|
+
def test_nonaccepted_200_throws_solve_failure(sdk, transport, status):
|
|
312
|
+
transport["response"] = Response(accepted(status=status, headers={}, errors=[KEY]))
|
|
313
|
+
with pytest.raises(sdk.SolveFailedError) as caught:
|
|
314
|
+
sdk.solve(TARGET, KEY)
|
|
315
|
+
assert caught.value.result.status == status
|
|
316
|
+
assert caught.value.result.errors == [KEY]
|
|
317
|
+
assert KEY not in str(caught.value) + repr(caught.value) + repr(caught.value.result)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.mark.parametrize(
|
|
321
|
+
"changes",
|
|
322
|
+
[
|
|
323
|
+
{"status": "unexpected"},
|
|
324
|
+
{"method": "none"},
|
|
325
|
+
{"target_url": "https://different.example/"},
|
|
326
|
+
{"duration_ms": -1},
|
|
327
|
+
{"duration_ms": True},
|
|
328
|
+
{"duration_ms": 1.5},
|
|
329
|
+
{"headers": {"X-Prefix-a": "a"}},
|
|
330
|
+
{"headers": {f"X-Prefix-{s}": "" for s in "abcdfz"}},
|
|
331
|
+
{"headers": {**accepted()["headers"], "X-Other-a": "a"}},
|
|
332
|
+
{"headers": {**accepted()["headers"], "X-RandomPrefix-a": 1}},
|
|
333
|
+
{"headers": {**accepted()["headers"], "X-RandomPrefix-a": "a\r\nBad: value"}},
|
|
334
|
+
{"cookies": {}},
|
|
335
|
+
{"cookies": [{"name": "n", "value": "v"}]},
|
|
336
|
+
{"cookies": [{"name": "n", "value": 1, "domain": "example.com", "path": "/"}]},
|
|
337
|
+
{"profile_used": []},
|
|
338
|
+
{"errors": "error"},
|
|
339
|
+
{"errors": [1]},
|
|
340
|
+
],
|
|
341
|
+
)
|
|
342
|
+
def test_malformed_response_is_rejected(sdk, transport, changes):
|
|
343
|
+
transport["response"] = Response(accepted(**changes))
|
|
344
|
+
with pytest.raises(sdk.InvalidResponseError):
|
|
345
|
+
sdk.solve(TARGET, KEY)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_missing_response_fields_are_not_synthesized(sdk, transport):
|
|
349
|
+
body = accepted()
|
|
350
|
+
del body["cookies"]
|
|
351
|
+
transport["response"] = Response(body)
|
|
352
|
+
with pytest.raises(sdk.InvalidResponseError):
|
|
353
|
+
sdk.solve(TARGET, KEY)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_actual_curl_adapter_disables_environment_proxy_without_network(
|
|
357
|
+
sdk, monkeypatch
|
|
358
|
+
):
|
|
359
|
+
from curl_cffi import Curl, CurlError, CurlOpt
|
|
360
|
+
|
|
361
|
+
options = []
|
|
362
|
+
original = Curl.setopt
|
|
363
|
+
|
|
364
|
+
def setopt(curl, option, value):
|
|
365
|
+
options.append((option, value))
|
|
366
|
+
return original(curl, option, value)
|
|
367
|
+
|
|
368
|
+
def perform(curl):
|
|
369
|
+
raise CurlError("Network disabled by test", code=7)
|
|
370
|
+
|
|
371
|
+
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.invalid:3128")
|
|
372
|
+
monkeypatch.setenv("ALL_PROXY", "http://proxy.invalid:3128")
|
|
373
|
+
monkeypatch.setattr(Curl, "setopt", setopt)
|
|
374
|
+
monkeypatch.setattr(Curl, "perform", perform)
|
|
375
|
+
with pytest.raises(sdk.TransportError):
|
|
376
|
+
sdk.solve(TARGET, KEY)
|
|
377
|
+
assert (CurlOpt.PROXY, "") in options
|
|
378
|
+
assert (CurlOpt.FOLLOWLOCATION, 0) in options
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@pytest.mark.parametrize(
|
|
382
|
+
"request_id,retry_after",
|
|
383
|
+
[
|
|
384
|
+
(KEY, KEY),
|
|
385
|
+
("prefix-" + KEY, "30\r\nBad: value"),
|
|
386
|
+
("x" * 129, "99999999999"),
|
|
387
|
+
("id\nsecret", "tomorrow"),
|
|
388
|
+
("Bearer secret", "Fri, 99 Oct 2026 00:00:00 GMT"),
|
|
389
|
+
],
|
|
390
|
+
)
|
|
391
|
+
def test_http_diagnostics_drop_reflected_credentials_and_invalid_headers(
|
|
392
|
+
sdk, transport, request_id, retry_after
|
|
393
|
+
):
|
|
394
|
+
transport["response"] = Response(
|
|
395
|
+
status=429, headers={"X-Request-ID": request_id, "Retry-After": retry_after}
|
|
396
|
+
)
|
|
397
|
+
with pytest.raises(sdk.HTTPError) as caught:
|
|
398
|
+
sdk.solve(TARGET, KEY)
|
|
399
|
+
assert caught.value.request_id is None
|
|
400
|
+
assert caught.value.retry_after is None
|
|
401
|
+
assert KEY not in str(caught.value.__dict__)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_valid_http_date_retry_after_is_preserved(sdk, transport):
|
|
405
|
+
value = "Mon, 12 Oct 2026 10:00:00 GMT"
|
|
406
|
+
transport["response"] = Response(status=429, headers={"Retry-After": value})
|
|
407
|
+
with pytest.raises(sdk.HTTPError) as caught:
|
|
408
|
+
sdk.solve(TARGET, KEY)
|
|
409
|
+
assert caught.value.retry_after == value
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_oversized_decoded_response_aborts_before_collecting_more(sdk, transport):
|
|
413
|
+
transport["chunks"] = [
|
|
414
|
+
b"x" * (1024 * 1024),
|
|
415
|
+
b"y" * (1024 * 1024),
|
|
416
|
+
b"z",
|
|
417
|
+
KEY.encode(),
|
|
418
|
+
]
|
|
419
|
+
with pytest.raises(sdk.InvalidResponseError) as caught:
|
|
420
|
+
sdk.solve(TARGET, KEY)
|
|
421
|
+
assert transport.get("aborted") is True
|
|
422
|
+
assert KEY not in str(caught.value)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_actual_adapter_body_callback_limits_decoded_bytes_and_preserves_timeout(
|
|
426
|
+
sdk, monkeypatch
|
|
427
|
+
):
|
|
428
|
+
from curl_cffi import Curl, CurlError, CurlOpt
|
|
429
|
+
|
|
430
|
+
options = {}
|
|
431
|
+
original = Curl.setopt
|
|
432
|
+
|
|
433
|
+
def setopt(curl, option, value):
|
|
434
|
+
options[option] = value
|
|
435
|
+
return original(curl, option, value)
|
|
436
|
+
|
|
437
|
+
def perform(curl):
|
|
438
|
+
callback = options.get(CurlOpt.WRITEFUNCTION)
|
|
439
|
+
if callable(callback):
|
|
440
|
+
assert callback(b"x" * (2 * 1024 * 1024)) == 2 * 1024 * 1024
|
|
441
|
+
assert callback(KEY.encode()) != len(KEY)
|
|
442
|
+
raise CurlError("Write aborted: " + KEY, code=23)
|
|
443
|
+
|
|
444
|
+
monkeypatch.setattr(Curl, "setopt", setopt)
|
|
445
|
+
monkeypatch.setattr(Curl, "perform", perform)
|
|
446
|
+
with pytest.raises(sdk.InvalidResponseError) as caught:
|
|
447
|
+
sdk.solve(TARGET, KEY)
|
|
448
|
+
assert callable(options[CurlOpt.WRITEFUNCTION])
|
|
449
|
+
assert options[CurlOpt.TIMEOUT_MS] == 150_000
|
|
450
|
+
assert KEY not in str(caught.value)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_actual_adapter_parses_json_from_multiple_decoded_chunks(sdk, monkeypatch):
|
|
454
|
+
import json
|
|
455
|
+
from curl_cffi import Curl, CurlOpt
|
|
456
|
+
|
|
457
|
+
options = {}
|
|
458
|
+
original = Curl.setopt
|
|
459
|
+
payload = json.dumps(accepted()).encode()
|
|
460
|
+
|
|
461
|
+
def setopt(curl, option, value):
|
|
462
|
+
options[option] = value
|
|
463
|
+
return original(curl, option, value)
|
|
464
|
+
|
|
465
|
+
def perform(curl):
|
|
466
|
+
callback = options[CurlOpt.WRITEFUNCTION]
|
|
467
|
+
assert callback(payload[:30]) == 30
|
|
468
|
+
assert callback(payload[30:]) == len(payload) - 30
|
|
469
|
+
|
|
470
|
+
def parse_response(*args, **kwargs):
|
|
471
|
+
response = requests.Response()
|
|
472
|
+
response.status_code = 200
|
|
473
|
+
response.headers = requests.Headers({"X-Request-ID": "req-123"})
|
|
474
|
+
return response
|
|
475
|
+
|
|
476
|
+
monkeypatch.setattr(Curl, "setopt", setopt)
|
|
477
|
+
monkeypatch.setattr(Curl, "perform", perform)
|
|
478
|
+
monkeypatch.setattr(requests.Session, "_parse_response", parse_response)
|
|
479
|
+
result = sdk.solve(TARGET, KEY)
|
|
480
|
+
assert result.headers == accepted()["headers"]
|
|
481
|
+
assert result.cookies == accepted()["cookies"]
|