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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,6 @@
1
+ curl_cffi<1,>=0.13
2
+
3
+ [dev]
4
+ pytest>=8
5
+ build>=1.2
6
+ twine>=6
@@ -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"]