unique-search-proxy-core 2026.44.0.dev7__tar.gz → 2026.44.0.dev9__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.
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/PKG-INFO +1 -1
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/pyproject.toml +1 -1
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/http_client/__init__.py +2 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/http_client/response_cap.py +54 -6
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/__init__.py +2 -0
- unique_search_proxy_core-2026.44.0.dev9/unique_search_proxy_core/url_safety/egress.py +150 -0
- unique_search_proxy_core-2026.44.0.dev7/unique_search_proxy_core/url_safety/egress.py +0 -96
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/README.md +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/bing/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/bing/enums.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/bing/grounding.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/bing/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/bing/settings.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/config_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/output_schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/resolve.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/response_parsing.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/vertexai/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/vertexai/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/agent_engines/vertexai/settings.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/context.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/basic/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/basic/content_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/basic/processing/policy.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/basic/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/config_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/firecrawl/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/jina/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/crawlers/tavily/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/errors.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/http_client/client.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/http_client/credentials.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/http_client/secrets.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/http_client/settings.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/logging.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/param_policy/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/param_policy/annotations.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/param_policy/derive.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/param_policy/exposable_config.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/param_policy/exposable_param.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/param_policy/exposed_params.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/param_policy/request_base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/providers/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/brave/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/brave/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/config_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/custom_api/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/custom_api/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/google/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/google/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/perplexity/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/search_engines/perplexity/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/dns.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/models.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/netloc.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/policy.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/redirect.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/resolver.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/service.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/unique_search_proxy_core/url_safety/settings.py +0 -0
{unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/pyproject.toml
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique-search-proxy-core"
|
|
3
|
-
version = "2026.44.0.
|
|
3
|
+
version = "2026.44.0.dev9"
|
|
4
4
|
description = "Shared Pydantic types for the Unique Search Proxy API"
|
|
5
5
|
authors = [{ name = "ThePhilAz", email = "rami.azouz@philico.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -19,6 +19,7 @@ from unique_search_proxy_core.http_client.credentials import (
|
|
|
19
19
|
)
|
|
20
20
|
from unique_search_proxy_core.http_client.response_cap import (
|
|
21
21
|
MAX_RESPONSE_BYTES_EXTENSION,
|
|
22
|
+
ByteBudget,
|
|
22
23
|
install_response_size_cap,
|
|
23
24
|
read_capped_body,
|
|
24
25
|
response_size_cap_hook,
|
|
@@ -36,6 +37,7 @@ from unique_search_proxy_core.http_client.settings import (
|
|
|
36
37
|
|
|
37
38
|
__all__ = [
|
|
38
39
|
"MAX_RESPONSE_BYTES_EXTENSION",
|
|
40
|
+
"ByteBudget",
|
|
39
41
|
"DirectRoute",
|
|
40
42
|
"EgressRoute",
|
|
41
43
|
"HttpClientRegistry",
|
|
@@ -115,10 +115,45 @@ def install_response_size_cap(
|
|
|
115
115
|
return client
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
class ByteBudget:
|
|
119
|
+
"""Shared allowance of decoded body bytes across several responses.
|
|
120
|
+
|
|
121
|
+
Bounds what one caller keeps in memory at once, e.g. every page of a crawl
|
|
122
|
+
request, which a per-response limit alone cannot do. Not thread-safe; share
|
|
123
|
+
it only between tasks of one event loop.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, max_bytes: int) -> None:
|
|
127
|
+
self._max_bytes = max_bytes
|
|
128
|
+
self._used = 0
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def exhausted(self) -> bool:
|
|
132
|
+
return self._used >= self._max_bytes
|
|
133
|
+
|
|
134
|
+
def consume(self, num_bytes: int) -> None:
|
|
135
|
+
"""Reserve bytes, raising once the budget is exceeded.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ResponseTooLargeError: When the reservation exceeds the budget.
|
|
139
|
+
"""
|
|
140
|
+
self._used += num_bytes
|
|
141
|
+
if self._used > self._max_bytes:
|
|
142
|
+
raise ResponseTooLargeError(
|
|
143
|
+
f"Combined upstream responses exceeded the {self._max_bytes} "
|
|
144
|
+
"byte budget",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def release(self, num_bytes: int) -> None:
|
|
148
|
+
"""Return bytes from a body that was discarded."""
|
|
149
|
+
self._used = max(0, self._used - num_bytes)
|
|
150
|
+
|
|
151
|
+
|
|
118
152
|
async def read_capped_body(
|
|
119
153
|
response: httpx.Response,
|
|
120
154
|
*,
|
|
121
155
|
max_bytes: int | None,
|
|
156
|
+
budget: ByteBudget | None = None,
|
|
122
157
|
) -> bytes:
|
|
123
158
|
"""Read a streamed response body, limiting its decompressed size.
|
|
124
159
|
|
|
@@ -129,25 +164,38 @@ async def read_capped_body(
|
|
|
129
164
|
response (httpx.Response): Response opened with ``stream=True``.
|
|
130
165
|
max_bytes (int | None): Largest decoded body allowed. ``None`` reads
|
|
131
166
|
the whole body.
|
|
167
|
+
budget (ByteBudget | None): Shared budget the body is charged against.
|
|
168
|
+
Bytes are returned to it when the read fails.
|
|
132
169
|
|
|
133
170
|
Returns:
|
|
134
171
|
bytes: The decoded response body.
|
|
135
172
|
|
|
136
173
|
Raises:
|
|
137
|
-
ResponseTooLargeError: When the decoded body exceeds ``max_bytes
|
|
174
|
+
ResponseTooLargeError: When the decoded body exceeds ``max_bytes`` or
|
|
175
|
+
the shared budget.
|
|
138
176
|
"""
|
|
139
177
|
chunks: list[bytes] = []
|
|
140
178
|
received = 0
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
179
|
+
charged = 0
|
|
180
|
+
try:
|
|
181
|
+
async for chunk in response.aiter_bytes():
|
|
182
|
+
received += len(chunk)
|
|
183
|
+
if max_bytes is not None and received > max_bytes:
|
|
184
|
+
raise ResponseTooLargeError.for_limit(max_bytes)
|
|
185
|
+
if budget is not None:
|
|
186
|
+
charged += len(chunk)
|
|
187
|
+
budget.consume(len(chunk))
|
|
188
|
+
chunks.append(chunk)
|
|
189
|
+
except BaseException:
|
|
190
|
+
if budget is not None:
|
|
191
|
+
budget.release(charged)
|
|
192
|
+
raise
|
|
146
193
|
return b"".join(chunks)
|
|
147
194
|
|
|
148
195
|
|
|
149
196
|
__all__ = [
|
|
150
197
|
"MAX_RESPONSE_BYTES_EXTENSION",
|
|
198
|
+
"ByteBudget",
|
|
151
199
|
"ResponseHook",
|
|
152
200
|
"install_response_size_cap",
|
|
153
201
|
"read_capped_body",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from unique_search_proxy_core.url_safety.egress import (
|
|
2
2
|
pinned_httpx_get_args,
|
|
3
3
|
safe_pinned_httpx_get,
|
|
4
|
+
safe_pinned_httpx_stream,
|
|
4
5
|
)
|
|
5
6
|
from unique_search_proxy_core.url_safety.models import (
|
|
6
7
|
BlockedCrawlTarget,
|
|
@@ -29,5 +30,6 @@ __all__ = [
|
|
|
29
30
|
"bypass_crawl_target",
|
|
30
31
|
"pinned_httpx_get_args",
|
|
31
32
|
"safe_pinned_httpx_get",
|
|
33
|
+
"safe_pinned_httpx_stream",
|
|
32
34
|
"url_safety_settings",
|
|
33
35
|
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Mapping
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from unique_search_proxy_core.url_safety.models import (
|
|
10
|
+
BlockedCrawlTarget,
|
|
11
|
+
CrawlTargetValidationError,
|
|
12
|
+
ResolvedCrawlTarget,
|
|
13
|
+
bypass_crawl_target,
|
|
14
|
+
)
|
|
15
|
+
from unique_search_proxy_core.url_safety.redirect import redirect_target_url
|
|
16
|
+
from unique_search_proxy_core.url_safety.resolver import resolve_crawl_target
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pinned_httpx_get_args(
|
|
20
|
+
target: ResolvedCrawlTarget,
|
|
21
|
+
) -> tuple[str, dict[str, str], dict[str, str]]:
|
|
22
|
+
"""Build httpx GET url, headers, and extensions for DNS-pinned egress."""
|
|
23
|
+
headers: dict[str, str] = {}
|
|
24
|
+
if target.host_header is not None:
|
|
25
|
+
headers["Host"] = target.host_header
|
|
26
|
+
|
|
27
|
+
extensions: dict[str, str] = {}
|
|
28
|
+
if target.sni_hostname is not None:
|
|
29
|
+
extensions["sni_hostname"] = target.sni_hostname
|
|
30
|
+
|
|
31
|
+
return target.request_url, headers, extensions
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _blocked_redirect(target: ResolvedCrawlTarget, reason: str) -> BlockedCrawlTarget:
|
|
35
|
+
return BlockedCrawlTarget(
|
|
36
|
+
hostname=target.hostname or None,
|
|
37
|
+
category="redirect",
|
|
38
|
+
reason=reason,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _next_redirect_url(
|
|
43
|
+
response: httpx.Response,
|
|
44
|
+
current_target: ResolvedCrawlTarget,
|
|
45
|
+
) -> str | None:
|
|
46
|
+
try:
|
|
47
|
+
next_url = redirect_target_url(response, current_target.normalized_url)
|
|
48
|
+
if next_url is not None:
|
|
49
|
+
parsed_next_url = urlsplit(next_url)
|
|
50
|
+
_ = parsed_next_url.hostname
|
|
51
|
+
_ = parsed_next_url.port
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise CrawlTargetValidationError(
|
|
54
|
+
[
|
|
55
|
+
_blocked_redirect(
|
|
56
|
+
current_target,
|
|
57
|
+
"Redirect target URL is missing or malformed",
|
|
58
|
+
)
|
|
59
|
+
]
|
|
60
|
+
) from exc
|
|
61
|
+
return next_url
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def safe_pinned_httpx_stream(
|
|
66
|
+
client: httpx.AsyncClient,
|
|
67
|
+
target: ResolvedCrawlTarget,
|
|
68
|
+
*,
|
|
69
|
+
headers: Mapping[str, str] | None = None,
|
|
70
|
+
timeout: float | httpx.Timeout,
|
|
71
|
+
max_redirect_hops: int,
|
|
72
|
+
enforce_url_safety: bool,
|
|
73
|
+
) -> AsyncIterator[httpx.Response]:
|
|
74
|
+
"""Open a streamed GET, applying the original target's safety mode to redirects.
|
|
75
|
+
|
|
76
|
+
Redirect hops are closed without reading their bodies. The final response
|
|
77
|
+
is yielded unread so the caller decides how much of the body to consume;
|
|
78
|
+
it is closed when the context exits.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
CrawlTargetValidationError: When a redirect is malformed, blocked, or
|
|
82
|
+
exceeds ``max_redirect_hops``.
|
|
83
|
+
"""
|
|
84
|
+
if max_redirect_hops < 0:
|
|
85
|
+
raise ValueError("max_redirect_hops must be non-negative")
|
|
86
|
+
|
|
87
|
+
current_target = target
|
|
88
|
+
for redirects_followed in range(max_redirect_hops + 1):
|
|
89
|
+
request_url, pin_headers, extensions = pinned_httpx_get_args(current_target)
|
|
90
|
+
request = client.build_request(
|
|
91
|
+
"GET",
|
|
92
|
+
request_url,
|
|
93
|
+
headers={**(headers or {}), **pin_headers},
|
|
94
|
+
extensions=extensions or None,
|
|
95
|
+
timeout=timeout,
|
|
96
|
+
)
|
|
97
|
+
response = await client.send(request, stream=True, follow_redirects=False)
|
|
98
|
+
try:
|
|
99
|
+
next_url = _next_redirect_url(response, current_target)
|
|
100
|
+
if next_url is None:
|
|
101
|
+
yield response
|
|
102
|
+
return
|
|
103
|
+
if redirects_followed == max_redirect_hops:
|
|
104
|
+
raise CrawlTargetValidationError(
|
|
105
|
+
[
|
|
106
|
+
_blocked_redirect(
|
|
107
|
+
current_target,
|
|
108
|
+
"Maximum redirect hop count exceeded",
|
|
109
|
+
)
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
finally:
|
|
113
|
+
await response.aclose()
|
|
114
|
+
|
|
115
|
+
current_target = (
|
|
116
|
+
await resolve_crawl_target(next_url)
|
|
117
|
+
if enforce_url_safety
|
|
118
|
+
else bypass_crawl_target(next_url)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
raise AssertionError("redirect loop must return or raise")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def safe_pinned_httpx_get(
|
|
125
|
+
client: httpx.AsyncClient,
|
|
126
|
+
target: ResolvedCrawlTarget,
|
|
127
|
+
*,
|
|
128
|
+
headers: Mapping[str, str] | None = None,
|
|
129
|
+
timeout: float | httpx.Timeout,
|
|
130
|
+
max_redirect_hops: int,
|
|
131
|
+
enforce_url_safety: bool,
|
|
132
|
+
) -> httpx.Response:
|
|
133
|
+
"""GET a target, applying the original target's safety mode to redirects."""
|
|
134
|
+
async with safe_pinned_httpx_stream(
|
|
135
|
+
client,
|
|
136
|
+
target,
|
|
137
|
+
headers=headers,
|
|
138
|
+
timeout=timeout,
|
|
139
|
+
max_redirect_hops=max_redirect_hops,
|
|
140
|
+
enforce_url_safety=enforce_url_safety,
|
|
141
|
+
) as response:
|
|
142
|
+
await response.aread()
|
|
143
|
+
return response
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"pinned_httpx_get_args",
|
|
148
|
+
"safe_pinned_httpx_get",
|
|
149
|
+
"safe_pinned_httpx_stream",
|
|
150
|
+
]
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from collections.abc import Mapping
|
|
4
|
-
from urllib.parse import urlsplit
|
|
5
|
-
|
|
6
|
-
import httpx
|
|
7
|
-
|
|
8
|
-
from unique_search_proxy_core.url_safety.models import (
|
|
9
|
-
BlockedCrawlTarget,
|
|
10
|
-
CrawlTargetValidationError,
|
|
11
|
-
ResolvedCrawlTarget,
|
|
12
|
-
bypass_crawl_target,
|
|
13
|
-
)
|
|
14
|
-
from unique_search_proxy_core.url_safety.redirect import redirect_target_url
|
|
15
|
-
from unique_search_proxy_core.url_safety.resolver import resolve_crawl_target
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def pinned_httpx_get_args(
|
|
19
|
-
target: ResolvedCrawlTarget,
|
|
20
|
-
) -> tuple[str, dict[str, str], dict[str, str]]:
|
|
21
|
-
"""Build httpx GET url, headers, and extensions for DNS-pinned egress."""
|
|
22
|
-
headers: dict[str, str] = {}
|
|
23
|
-
if target.host_header is not None:
|
|
24
|
-
headers["Host"] = target.host_header
|
|
25
|
-
|
|
26
|
-
extensions: dict[str, str] = {}
|
|
27
|
-
if target.sni_hostname is not None:
|
|
28
|
-
extensions["sni_hostname"] = target.sni_hostname
|
|
29
|
-
|
|
30
|
-
return target.request_url, headers, extensions
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
async def safe_pinned_httpx_get(
|
|
34
|
-
client: httpx.AsyncClient,
|
|
35
|
-
target: ResolvedCrawlTarget,
|
|
36
|
-
*,
|
|
37
|
-
headers: Mapping[str, str] | None = None,
|
|
38
|
-
timeout: float | httpx.Timeout,
|
|
39
|
-
max_redirect_hops: int,
|
|
40
|
-
enforce_url_safety: bool,
|
|
41
|
-
) -> httpx.Response:
|
|
42
|
-
"""GET a target, applying the original target's safety mode to redirects."""
|
|
43
|
-
if max_redirect_hops < 0:
|
|
44
|
-
raise ValueError("max_redirect_hops must be non-negative")
|
|
45
|
-
|
|
46
|
-
current_target = target
|
|
47
|
-
for redirects_followed in range(max_redirect_hops + 1):
|
|
48
|
-
request_url, pin_headers, extensions = pinned_httpx_get_args(current_target)
|
|
49
|
-
response = await client.get(
|
|
50
|
-
request_url,
|
|
51
|
-
headers={**(headers or {}), **pin_headers},
|
|
52
|
-
extensions=extensions or None,
|
|
53
|
-
timeout=timeout,
|
|
54
|
-
follow_redirects=False,
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
try:
|
|
58
|
-
next_url = redirect_target_url(response, current_target.normalized_url)
|
|
59
|
-
if next_url is not None:
|
|
60
|
-
parsed_next_url = urlsplit(next_url)
|
|
61
|
-
_ = parsed_next_url.hostname
|
|
62
|
-
_ = parsed_next_url.port
|
|
63
|
-
except ValueError as exc:
|
|
64
|
-
raise CrawlTargetValidationError(
|
|
65
|
-
[
|
|
66
|
-
BlockedCrawlTarget(
|
|
67
|
-
hostname=current_target.hostname or None,
|
|
68
|
-
category="redirect",
|
|
69
|
-
reason="Redirect target URL is missing or malformed",
|
|
70
|
-
)
|
|
71
|
-
]
|
|
72
|
-
) from exc
|
|
73
|
-
if next_url is None:
|
|
74
|
-
return response
|
|
75
|
-
|
|
76
|
-
if redirects_followed == max_redirect_hops:
|
|
77
|
-
raise CrawlTargetValidationError(
|
|
78
|
-
[
|
|
79
|
-
BlockedCrawlTarget(
|
|
80
|
-
hostname=current_target.hostname or None,
|
|
81
|
-
category="redirect",
|
|
82
|
-
reason="Maximum redirect hop count exceeded",
|
|
83
|
-
)
|
|
84
|
-
]
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
current_target = (
|
|
88
|
-
await resolve_crawl_target(next_url)
|
|
89
|
-
if enforce_url_safety
|
|
90
|
-
else bypass_crawl_target(next_url)
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
raise AssertionError("redirect loop must return or raise")
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
__all__ = ["pinned_httpx_get_args", "safe_pinned_httpx_get"]
|
{unique_search_proxy_core-2026.44.0.dev7 → unique_search_proxy_core-2026.44.0.dev9}/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|