unique-search-proxy-core 2026.44.0.dev6__tar.gz → 2026.44.0.dev8__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.dev6 → unique_search_proxy_core-2026.44.0.dev8}/PKG-INFO +1 -1
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/pyproject.toml +1 -1
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/errors.py +12 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/http_client/__init__.py +10 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/http_client/client.py +16 -0
- unique_search_proxy_core-2026.44.0.dev8/unique_search_proxy_core/http_client/response_cap.py +155 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/http_client/settings.py +16 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/schema.py +1 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/__init__.py +2 -0
- unique_search_proxy_core-2026.44.0.dev8/unique_search_proxy_core/url_safety/egress.py +150 -0
- unique_search_proxy_core-2026.44.0.dev6/unique_search_proxy_core/url_safety/egress.py +0 -96
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/README.md +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/bing/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/bing/enums.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/bing/grounding.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/bing/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/bing/settings.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/config_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/output_schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/resolve.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/response_parsing.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/vertexai/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/vertexai/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/agent_engines/vertexai/settings.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/context.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/basic/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/basic/content_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/basic/processing/policy.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/basic/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/config_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/firecrawl/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/jina/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/crawlers/tavily/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/http_client/credentials.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/http_client/secrets.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/logging.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/param_policy/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/param_policy/annotations.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/param_policy/derive.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/param_policy/exposable_config.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/param_policy/exposable_param.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/param_policy/exposed_params.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/param_policy/request_base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/providers/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/brave/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/brave/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/config_types.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/custom_api/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/custom_api/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/google/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/google/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/perplexity/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/search_engines/perplexity/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/dns.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/models.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/netloc.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/policy.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/redirect.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/resolver.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/service.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/unique_search_proxy_core/url_safety/settings.py +0 -0
{unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev8}/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.dev8"
|
|
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"
|
|
@@ -132,3 +132,15 @@ class EmptySearchResultsError(ProxyError):
|
|
|
132
132
|
code = ProxyErrorCode.EMPTY_SEARCH_RESULTS
|
|
133
133
|
status_code = 404
|
|
134
134
|
retryable = False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ResponseTooLargeError(ProxyError):
|
|
138
|
+
"""An upstream response body exceeded the configured byte limit."""
|
|
139
|
+
|
|
140
|
+
code = ProxyErrorCode.RESPONSE_TOO_LARGE
|
|
141
|
+
status_code = 502
|
|
142
|
+
retryable = False
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def for_limit(cls, max_bytes: int) -> ResponseTooLargeError:
|
|
146
|
+
return cls(f"Upstream response exceeded the {max_bytes} byte limit")
|
|
@@ -17,6 +17,12 @@ from unique_search_proxy_core.http_client.credentials import (
|
|
|
17
17
|
UserMetadataProxyCredentials,
|
|
18
18
|
resolver_from_settings,
|
|
19
19
|
)
|
|
20
|
+
from unique_search_proxy_core.http_client.response_cap import (
|
|
21
|
+
MAX_RESPONSE_BYTES_EXTENSION,
|
|
22
|
+
install_response_size_cap,
|
|
23
|
+
read_capped_body,
|
|
24
|
+
response_size_cap_hook,
|
|
25
|
+
)
|
|
20
26
|
from unique_search_proxy_core.http_client.secrets import (
|
|
21
27
|
read_secret,
|
|
22
28
|
read_secret_mapping,
|
|
@@ -29,6 +35,7 @@ from unique_search_proxy_core.http_client.settings import (
|
|
|
29
35
|
)
|
|
30
36
|
|
|
31
37
|
__all__ = [
|
|
38
|
+
"MAX_RESPONSE_BYTES_EXTENSION",
|
|
32
39
|
"DirectRoute",
|
|
33
40
|
"EgressRoute",
|
|
34
41
|
"HttpClientRegistry",
|
|
@@ -45,7 +52,10 @@ __all__ = [
|
|
|
45
52
|
"async_client_factory",
|
|
46
53
|
"build_async_client",
|
|
47
54
|
"build_route",
|
|
55
|
+
"install_response_size_cap",
|
|
56
|
+
"read_capped_body",
|
|
48
57
|
"read_secret",
|
|
49
58
|
"read_secret_mapping",
|
|
50
59
|
"resolver_from_settings",
|
|
60
|
+
"response_size_cap_hook",
|
|
51
61
|
]
|
|
@@ -20,6 +20,10 @@ from unique_search_proxy_core.http_client.credentials import (
|
|
|
20
20
|
SettingsProxyCredentials,
|
|
21
21
|
resolver_from_settings,
|
|
22
22
|
)
|
|
23
|
+
from unique_search_proxy_core.http_client.response_cap import (
|
|
24
|
+
ResponseHook,
|
|
25
|
+
response_size_cap_hook,
|
|
26
|
+
)
|
|
23
27
|
from unique_search_proxy_core.http_client.secrets import read_secret_mapping
|
|
24
28
|
from unique_search_proxy_core.http_client.settings import (
|
|
25
29
|
ProxyAuthMode,
|
|
@@ -96,6 +100,10 @@ def _proxy_headers(settings: ProxySettings) -> dict[str, str]:
|
|
|
96
100
|
return read_secret_mapping(settings.proxy_headers)
|
|
97
101
|
|
|
98
102
|
|
|
103
|
+
def _event_hooks(settings: ProxySettings) -> dict[str, list[ResponseHook]]:
|
|
104
|
+
return {"response": [response_size_cap_hook(settings.max_response_bytes)]}
|
|
105
|
+
|
|
106
|
+
|
|
99
107
|
def _build_httpx_proxy(
|
|
100
108
|
settings: ProxySettings,
|
|
101
109
|
credentials: ProxyCredentials,
|
|
@@ -174,6 +182,7 @@ def build_async_client(
|
|
|
174
182
|
trust_env=route.trust_env,
|
|
175
183
|
timeout=timeout,
|
|
176
184
|
limits=limits,
|
|
185
|
+
event_hooks=_event_hooks(settings),
|
|
177
186
|
)
|
|
178
187
|
case ProxiedRoute():
|
|
179
188
|
return AsyncClient(
|
|
@@ -184,6 +193,7 @@ def build_async_client(
|
|
|
184
193
|
cert=route.cert,
|
|
185
194
|
timeout=timeout,
|
|
186
195
|
limits=limits,
|
|
196
|
+
event_hooks=_event_hooks(settings),
|
|
187
197
|
)
|
|
188
198
|
|
|
189
199
|
|
|
@@ -207,6 +217,7 @@ def async_client_factory(
|
|
|
207
217
|
verify=route.verify,
|
|
208
218
|
trust_env=route.trust_env,
|
|
209
219
|
timeout=effective_timeout,
|
|
220
|
+
event_hooks=_event_hooks(settings),
|
|
210
221
|
)
|
|
211
222
|
case ProxiedRoute():
|
|
212
223
|
return partial(
|
|
@@ -217,6 +228,7 @@ def async_client_factory(
|
|
|
217
228
|
trust_env=route.trust_env,
|
|
218
229
|
cert=route.cert,
|
|
219
230
|
timeout=effective_timeout,
|
|
231
|
+
event_hooks=_event_hooks(settings),
|
|
220
232
|
)
|
|
221
233
|
|
|
222
234
|
|
|
@@ -283,6 +295,10 @@ class HttpClientRegistry:
|
|
|
283
295
|
) -> None:
|
|
284
296
|
if fixed_client is None:
|
|
285
297
|
_validate_url_safety_proxy_configuration(settings)
|
|
298
|
+
_LOGGER.info(
|
|
299
|
+
"Outbound response size limit: %s bytes",
|
|
300
|
+
settings.max_response_bytes or "unlimited",
|
|
301
|
+
)
|
|
286
302
|
self._settings = settings
|
|
287
303
|
self._resolver = resolver
|
|
288
304
|
self._fixed_client = fixed_client
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Bound how many bytes an outbound response may deliver.
|
|
2
|
+
|
|
3
|
+
The limit is enforced while the body streams in, so an oversized, chunked, or
|
|
4
|
+
endless response is aborted before it is buffered in memory. It is installed as
|
|
5
|
+
an httpx response hook rather than a custom transport: a custom transport makes
|
|
6
|
+
httpx ignore ``trust_env`` proxy discovery and every transport-level option.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from unique_search_proxy_core.errors import ResponseTooLargeError
|
|
16
|
+
|
|
17
|
+
MAX_RESPONSE_BYTES_EXTENSION = "max_response_bytes"
|
|
18
|
+
|
|
19
|
+
# Raw chunks are re-sliced before httpx decompresses them, which bounds how much
|
|
20
|
+
# a single gzip/deflate decode step can expand.
|
|
21
|
+
_RAW_SLICE_BYTES = 16 * 1024
|
|
22
|
+
|
|
23
|
+
ResponseHook = Callable[[httpx.Response], Awaitable[None]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _CappedRawStream(httpx.AsyncByteStream):
|
|
27
|
+
"""Wrap a response byte stream and abort once it delivers too many bytes.
|
|
28
|
+
|
|
29
|
+
Counts bytes as received from the network (before decompression), so it
|
|
30
|
+
also catches responses with a missing or understated ``Content-Length``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, stream: httpx.AsyncByteStream, *, max_bytes: int) -> None:
|
|
34
|
+
self._stream = stream
|
|
35
|
+
self._max_bytes = max_bytes
|
|
36
|
+
|
|
37
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
38
|
+
received = 0
|
|
39
|
+
async for chunk in self._stream:
|
|
40
|
+
received += len(chunk)
|
|
41
|
+
if received > self._max_bytes:
|
|
42
|
+
raise ResponseTooLargeError.for_limit(self._max_bytes)
|
|
43
|
+
for start in range(0, len(chunk), _RAW_SLICE_BYTES):
|
|
44
|
+
yield chunk[start : start + _RAW_SLICE_BYTES]
|
|
45
|
+
|
|
46
|
+
async def aclose(self) -> None:
|
|
47
|
+
await self._stream.aclose()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _effective_max_bytes(
|
|
51
|
+
request: httpx.Request,
|
|
52
|
+
default_max_bytes: int | None,
|
|
53
|
+
) -> int | None:
|
|
54
|
+
"""Combine the client default with a per-request override; lower wins."""
|
|
55
|
+
override = request.extensions.get(MAX_RESPONSE_BYTES_EXTENSION)
|
|
56
|
+
if override is None:
|
|
57
|
+
return default_max_bytes
|
|
58
|
+
if default_max_bytes is None:
|
|
59
|
+
return int(override)
|
|
60
|
+
return min(int(override), default_max_bytes)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _declared_content_length(response: httpx.Response) -> int | None:
|
|
64
|
+
header = response.headers.get("content-length")
|
|
65
|
+
if header is None:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return int(header)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def response_size_cap_hook(default_max_bytes: int | None) -> ResponseHook:
|
|
74
|
+
"""Build an httpx response hook that caps the response body size.
|
|
75
|
+
|
|
76
|
+
Requests can lower the limit with
|
|
77
|
+
``extensions={"max_response_bytes": n}``; they cannot raise it above
|
|
78
|
+
``default_max_bytes``.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
default_max_bytes (int | None): Limit applied when a request sets no
|
|
82
|
+
override. ``None`` leaves such requests uncapped.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
ResponseHook: Coroutine to register under ``event_hooks["response"]``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
async def _cap_response_size(response: httpx.Response) -> None:
|
|
89
|
+
max_bytes = _effective_max_bytes(response.request, default_max_bytes)
|
|
90
|
+
if max_bytes is None:
|
|
91
|
+
return
|
|
92
|
+
declared = _declared_content_length(response)
|
|
93
|
+
if declared is not None and declared > max_bytes:
|
|
94
|
+
raise ResponseTooLargeError.for_limit(max_bytes)
|
|
95
|
+
if isinstance(response.stream, httpx.AsyncByteStream):
|
|
96
|
+
response.stream = _CappedRawStream(response.stream, max_bytes=max_bytes)
|
|
97
|
+
|
|
98
|
+
return _cap_response_size
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def install_response_size_cap(
|
|
102
|
+
client: httpx.AsyncClient,
|
|
103
|
+
max_bytes: int | None,
|
|
104
|
+
) -> httpx.AsyncClient:
|
|
105
|
+
"""Register the response size cap on an existing client.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
client (httpx.AsyncClient): Client to protect, e.g. a test client.
|
|
109
|
+
max_bytes (int | None): Default limit for the client's requests.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
httpx.AsyncClient: The same client, for chaining.
|
|
113
|
+
"""
|
|
114
|
+
client.event_hooks["response"].append(response_size_cap_hook(max_bytes))
|
|
115
|
+
return client
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def read_capped_body(
|
|
119
|
+
response: httpx.Response,
|
|
120
|
+
*,
|
|
121
|
+
max_bytes: int | None,
|
|
122
|
+
) -> bytes:
|
|
123
|
+
"""Read a streamed response body, limiting its decompressed size.
|
|
124
|
+
|
|
125
|
+
The response hook limits bytes received from the network; this limits the
|
|
126
|
+
bytes after gzip/deflate decoding, which stops decompression bombs.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
response (httpx.Response): Response opened with ``stream=True``.
|
|
130
|
+
max_bytes (int | None): Largest decoded body allowed. ``None`` reads
|
|
131
|
+
the whole body.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
bytes: The decoded response body.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ResponseTooLargeError: When the decoded body exceeds ``max_bytes``.
|
|
138
|
+
"""
|
|
139
|
+
chunks: list[bytes] = []
|
|
140
|
+
received = 0
|
|
141
|
+
async for chunk in response.aiter_bytes():
|
|
142
|
+
received += len(chunk)
|
|
143
|
+
if max_bytes is not None and received > max_bytes:
|
|
144
|
+
raise ResponseTooLargeError.for_limit(max_bytes)
|
|
145
|
+
chunks.append(chunk)
|
|
146
|
+
return b"".join(chunks)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = [
|
|
150
|
+
"MAX_RESPONSE_BYTES_EXTENSION",
|
|
151
|
+
"ResponseHook",
|
|
152
|
+
"install_response_size_cap",
|
|
153
|
+
"read_capped_body",
|
|
154
|
+
"response_size_cap_hook",
|
|
155
|
+
]
|
|
@@ -59,6 +59,22 @@ class ProxySettings(BaseSettings):
|
|
|
59
59
|
pool_timeout_seconds: float = 30.0
|
|
60
60
|
max_connections: int = 100
|
|
61
61
|
max_keepalive_connections: int = 20
|
|
62
|
+
# KiB rather than bytes: Helm renders numbers >= 1e6 in scientific notation,
|
|
63
|
+
# which pydantic cannot parse back into an int.
|
|
64
|
+
max_response_kib: int | None = Field(
|
|
65
|
+
default=5 * 1024,
|
|
66
|
+
ge=1,
|
|
67
|
+
description=(
|
|
68
|
+
"Largest upstream response body, in KiB, any outbound request may "
|
|
69
|
+
"deliver before it is aborted. None disables the limit."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def max_response_bytes(self) -> int | None:
|
|
75
|
+
if self.max_response_kib is None:
|
|
76
|
+
return None
|
|
77
|
+
return self.max_response_kib * 1024
|
|
62
78
|
|
|
63
79
|
@model_validator(mode="after")
|
|
64
80
|
def _validate_proxy_credentials_config(self) -> Self:
|
|
@@ -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.dev6 → unique_search_proxy_core-2026.44.0.dev8}/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
|