unique-search-proxy-core 2026.44.0.dev6__tar.gz → 2026.44.0.dev7__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.dev7}/PKG-INFO +1 -1
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/pyproject.toml +1 -1
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/unique_search_proxy_core/errors.py +12 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/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.dev7}/unique_search_proxy_core/http_client/client.py +16 -0
- unique_search_proxy_core-2026.44.0.dev7/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.dev7}/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.dev7}/unique_search_proxy_core/schema.py +1 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/README.md +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/unique_search_proxy_core/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/unique_search_proxy_core/context.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/unique_search_proxy_core/crawlers/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/unique_search_proxy_core/crawlers/base.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/unique_search_proxy_core/logging.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/unique_search_proxy_core/providers/schema.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/unique_search_proxy_core/url_safety/__init__.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/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.dev7}/unique_search_proxy_core/url_safety/egress.py +0 -0
- {unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7}/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.dev7"
|
|
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:
|
{unique_search_proxy_core-2026.44.0.dev6 → unique_search_proxy_core-2026.44.0.dev7}/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
|