Skip to content

Commit 9171fff

Browse files
committed
Add sync httpx node
1 parent 6da4c83 commit 9171fff

File tree

11 files changed

+321
-21
lines changed

11 files changed

+321
-21
lines changed

docs/sphinx/installation.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ Install the ``requests`` package to use :class:`elastic_transport.RequestsHttpNo
1111

1212
Install the ``aiohttp`` package to use :class:`elastic_transport.AiohttpHttpNode`.
1313

14-
Install the ``httpx`` package to use :class:`elastic_transport.HttpxAsyncHttpNode`.
14+
Install the ``httpx`` package to use :class:`elastic_transport.HttpxHttpNode` or :class:`elastic_transport.HttpxAsyncHttpNode`.

docs/sphinx/nodes.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ Node classes
2222
.. autoclass:: AiohttpHttpNode
2323
:members:
2424

25+
.. autoclass:: HttpxHttpNode
26+
:members:
27+
2528
.. autoclass:: HttpxAsyncHttpNode
2629
:members:
2730

elastic_transport/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
BaseAsyncNode,
3838
BaseNode,
3939
HttpxAsyncHttpNode,
40+
HttpxHttpNode,
4041
RequestsHttpNode,
4142
Urllib3HttpNode,
4243
)
@@ -74,6 +75,7 @@
7475
"HeadApiResponse",
7576
"HttpHeaders",
7677
"HttpxAsyncHttpNode",
78+
"HttpxHttpNode",
7779
"JsonSerializer",
7880
"ListApiResponse",
7981
"NdjsonSerializer",

elastic_transport/_node/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from ._base import BaseNode, NodeApiResponse
1919
from ._base_async import BaseAsyncNode
2020
from ._http_aiohttp import AiohttpHttpNode
21-
from ._http_httpx import HttpxAsyncHttpNode
21+
from ._http_httpx import HttpxAsyncHttpNode, HttpxHttpNode
2222
from ._http_requests import RequestsHttpNode
2323
from ._http_urllib3 import Urllib3HttpNode
2424

@@ -29,5 +29,6 @@
2929
"NodeApiResponse",
3030
"RequestsHttpNode",
3131
"Urllib3HttpNode",
32+
"HttpxHttpNode",
3233
"HttpxAsyncHttpNode",
3334
]

elastic_transport/_node/_http_httpx.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
BUILTIN_EXCEPTIONS,
3131
DEFAULT_CA_CERTS,
3232
RERAISE_EXCEPTIONS,
33+
BaseNode,
3334
NodeApiResponse,
3435
ssl_context_from_node_config,
3536
)
@@ -45,6 +46,161 @@
4546
_HTTPX_META_VERSION = ""
4647

4748

49+
class HttpxHttpNode(BaseNode):
50+
_CLIENT_META_HTTP_CLIENT = ("hx", _HTTPX_META_VERSION)
51+
52+
def __init__(self, config: NodeConfig):
53+
if not _HTTPX_AVAILABLE: # pragma: nocover
54+
raise ValueError("You must have 'httpx' installed to use HttpxNode")
55+
super().__init__(config)
56+
57+
if config.ssl_assert_fingerprint:
58+
raise ValueError(
59+
"httpx does not support certificate pinning. https://github.com/encode/httpx/issues/761"
60+
)
61+
62+
ssl_context: Union[ssl.SSLContext, Literal[False]] = False
63+
if config.scheme == "https":
64+
if config.ssl_context is not None:
65+
ssl_context = ssl_context_from_node_config(config)
66+
else:
67+
ssl_context = ssl_context_from_node_config(config)
68+
69+
ca_certs = (
70+
DEFAULT_CA_CERTS if config.ca_certs is None else config.ca_certs
71+
)
72+
if config.verify_certs:
73+
if not ca_certs:
74+
raise ValueError(
75+
"Root certificates are missing for certificate "
76+
"validation. Either pass them in using the ca_certs parameter or "
77+
"install certifi to use it automatically."
78+
)
79+
else:
80+
if config.ssl_show_warn:
81+
warnings.warn(
82+
f"Connecting to {self.base_url!r} using TLS with verify_certs=False is insecure",
83+
stacklevel=warn_stacklevel(),
84+
category=SecurityWarning,
85+
)
86+
87+
if ca_certs is not None:
88+
if os.path.isfile(ca_certs):
89+
ssl_context.load_verify_locations(cafile=ca_certs)
90+
elif os.path.isdir(ca_certs):
91+
ssl_context.load_verify_locations(capath=ca_certs)
92+
else:
93+
raise ValueError("ca_certs parameter is not a path")
94+
95+
# Use client_cert and client_key variables for SSL certificate configuration.
96+
if config.client_cert and not os.path.isfile(config.client_cert):
97+
raise ValueError("client_cert is not a path to a file")
98+
if config.client_key and not os.path.isfile(config.client_key):
99+
raise ValueError("client_key is not a path to a file")
100+
if config.client_cert and config.client_key:
101+
ssl_context.load_cert_chain(config.client_cert, config.client_key)
102+
elif config.client_cert:
103+
ssl_context.load_cert_chain(config.client_cert)
104+
105+
self.client = httpx.Client(
106+
base_url=f"{config.scheme}://{config.host}:{config.port}",
107+
limits=httpx.Limits(max_connections=config.connections_per_node),
108+
verify=ssl_context or False,
109+
timeout=config.request_timeout,
110+
)
111+
112+
def perform_request(
113+
self,
114+
method: str,
115+
target: str,
116+
body: Optional[bytes] = None,
117+
headers: Optional[HttpHeaders] = None,
118+
request_timeout: Union[DefaultType, Optional[float]] = DEFAULT,
119+
) -> NodeApiResponse:
120+
resolved_headers = self._headers.copy()
121+
if headers:
122+
resolved_headers.update(headers)
123+
124+
if body:
125+
if self._http_compress:
126+
resolved_body = gzip.compress(body)
127+
resolved_headers["content-encoding"] = "gzip"
128+
else:
129+
resolved_body = body
130+
else:
131+
resolved_body = None
132+
133+
try:
134+
start = time.perf_counter()
135+
if request_timeout is DEFAULT:
136+
resp = self.client.request(
137+
method,
138+
target,
139+
content=resolved_body,
140+
headers=dict(resolved_headers),
141+
)
142+
else:
143+
resp = self.client.request(
144+
method,
145+
target,
146+
content=resolved_body,
147+
headers=dict(resolved_headers),
148+
timeout=request_timeout,
149+
)
150+
response_body = resp.read()
151+
duration = time.perf_counter() - start
152+
except RERAISE_EXCEPTIONS + BUILTIN_EXCEPTIONS:
153+
raise
154+
except Exception as e:
155+
err: Exception
156+
if isinstance(e, (TimeoutError, httpx.TimeoutException)):
157+
err = ConnectionTimeout(
158+
"Connection timed out during request", errors=(e,)
159+
)
160+
elif isinstance(e, ssl.SSLError):
161+
err = TlsError(str(e), errors=(e,))
162+
# Detect SSL errors for httpx v0.28.0+
163+
# Needed until https://github.com/encode/httpx/issues/3350 is fixed
164+
elif isinstance(e, httpx.ConnectError) and e.__cause__:
165+
context = e.__cause__.__context__
166+
if isinstance(context, ssl.SSLError):
167+
err = TlsError(str(context), errors=(e,))
168+
else:
169+
err = ConnectionError(str(e), errors=(e,))
170+
else:
171+
err = ConnectionError(str(e), errors=(e,))
172+
self._log_request(
173+
method=method,
174+
target=target,
175+
headers=resolved_headers,
176+
body=body,
177+
exception=err,
178+
)
179+
raise err from None
180+
181+
meta = ApiResponseMeta(
182+
resp.status_code,
183+
resp.http_version,
184+
HttpHeaders(resp.headers),
185+
duration,
186+
self.config,
187+
)
188+
189+
self._log_request(
190+
method=method,
191+
target=target,
192+
headers=resolved_headers,
193+
body=body,
194+
meta=meta,
195+
response=response_body,
196+
)
197+
198+
return NodeApiResponse(meta, response_body)
199+
200+
def close(self) -> None:
201+
self.client.close()
202+
203+
48204
class HttpxAsyncHttpNode(BaseAsyncNode):
49205
_CLIENT_META_HTTP_CLIENT = ("hx", _HTTPX_META_VERSION)
50206

elastic_transport/_transport.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
AiohttpHttpNode,
5757
BaseNode,
5858
HttpxAsyncHttpNode,
59+
HttpxHttpNode,
5960
RequestsHttpNode,
6061
Urllib3HttpNode,
6162
)
@@ -70,6 +71,7 @@
7071
"urllib3": Urllib3HttpNode,
7172
"requests": RequestsHttpNode,
7273
"aiohttp": AiohttpHttpNode,
74+
"httpx": HttpxHttpNode,
7375
"httpxasync": HttpxAsyncHttpNode,
7476
}
7577
# These are HTTP status errors that shouldn't be considered

tests/async_/test_async_transport.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ async def test_node_class_as_string():
315315
AsyncTransport([NodeConfig("http", "localhost", 80)], node_class="huh?")
316316
assert str(e.value) == (
317317
"Unknown option for node_class: 'huh?'. "
318-
"Available options are: 'aiohttp', 'httpxasync', 'requests', 'urllib3'"
318+
"Available options are: 'aiohttp', 'httpx', 'httpxasync', 'requests', 'urllib3'"
319319
)
320320

321321

tests/node/test_base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from elastic_transport import (
2121
AiohttpHttpNode,
2222
HttpxAsyncHttpNode,
23+
HttpxHttpNode,
2324
NodeConfig,
2425
RequestsHttpNode,
2526
Urllib3HttpNode,
@@ -28,7 +29,14 @@
2829

2930

3031
@pytest.mark.parametrize(
31-
"node_cls", [Urllib3HttpNode, RequestsHttpNode, AiohttpHttpNode, HttpxAsyncHttpNode]
32+
"node_cls",
33+
[
34+
Urllib3HttpNode,
35+
RequestsHttpNode,
36+
AiohttpHttpNode,
37+
HttpxHttpNode,
38+
HttpxAsyncHttpNode,
39+
],
3240
)
3341
def test_unknown_parameter(node_cls):
3442
with pytest.raises(TypeError):

0 commit comments

Comments
 (0)