From 98e11ffe38623b6085b28906d92e43693633280b Mon Sep 17 00:00:00 2001 From: justinpolygon <123573436+justinpolygon@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:54:02 -0700 Subject: [PATCH 1/3] [SPT-1668] Add proxy, custom CA, and connection pool options Combines the community PRs that change how the REST client builds its urllib3 pool (#968, #1008, #1026, #1033, #1034). - CA bundle: both clients verify TLS with ca_certs if given, then the SSL_CERT_FILE or REQUESTS_CA_BUNDLE environment variable, then certifi (#998). - RESTClient(maxsize=10): connections kept per host. urllib3 defaults to 1, so threaded clients kept opening new TLS connections. - RESTClient(connection_pool_kw=...): other urllib3 pool options, merged over the defaults (#1009). - RESTClient(proxy=...): explicit proxy URL, with credentials from the URL. Proxy environment variables aren't read, so existing traffic doesn't change route. The WebSocket client already uses the system proxy via websockets. Co-authored-by: SACHU JAMES <142034811+SachuJames@users.noreply.github.com> Co-authored-by: Mohd Quamar Tyagi <104281681+Tyagiquamar@users.noreply.github.com> Co-authored-by: jakobovski-arb <201493996+jakobovski-arb@users.noreply.github.com> Co-authored-by: Roman Zydyk <31843161+romanzdk@users.noreply.github.com> Co-authored-by: Kevin Chiu <2712+kevinchiu@users.noreply.github.com> --- README.md | 20 +++++++++++ massive/certs.py | 17 +++++++++ massive/rest/__init__.py | 23 ++++++++++++- massive/rest/base.py | 38 +++++++++++++++----- massive/websocket/__init__.py | 19 ++++++---- test_rest/test_connection.py | 65 +++++++++++++++++++++++++++++++++++ test_websocket/test_ssl.py | 39 +++++++++++++++++++++ 7 files changed, 205 insertions(+), 16 deletions(-) create mode 100644 massive/certs.py create mode 100644 test_rest/test_connection.py create mode 100644 test_websocket/test_ssl.py diff --git a/README.md b/README.md index f0f1389d..74da2f48 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,26 @@ print(len(options_chain)) Also, please refer to the API documentation to get a full understanding of how the API works and the supported arguments. All required arguments are annotated with red asterisks " * " and argument examples are set. +## Proxies, Certificates, and Connections + +Behind a corporate proxy or TLS inspection, point the clients at your CA bundle and proxy: + +```python +from massive import RESTClient, WebSocketClient + +client = RESTClient( + proxy="http://user:pass@proxy.example.com:8080", # REST doesn't read HTTPS_PROXY + ca_certs="/path/to/corporate-ca.pem", # or set SSL_CERT_FILE / REQUESTS_CA_BUNDLE +) + +ws_client = WebSocketClient(ca_certs="/path/to/corporate-ca.pem") +ws_client.run(handle_msg, proxy="http://user:pass@proxy.example.com:8080") # defaults to the system proxy +``` + +Both clients verify TLS with `ca_certs` if given, then the `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` environment variable, then the bundled certifi certificates. + +`RESTClient` keeps up to `maxsize=10` connections open for reuse across threads. Other urllib3 pool options can be passed with `connection_pool_kw`. + ## Debugging with RESTClient Sometimes you may find it useful to see the actual request and response details while working with the API. The `RESTClient` allows for this through its `trace=True` option. diff --git a/massive/certs.py b/massive/certs.py new file mode 100644 index 00000000..30b94ce4 --- /dev/null +++ b/massive/certs.py @@ -0,0 +1,17 @@ +import os +import certifi + + +def ca_bundle_path() -> str: + """ + Path of the CA bundle used to verify TLS connections. + + Honors the standard ``SSL_CERT_FILE`` and ``REQUESTS_CA_BUNDLE`` environment + variables (used for example behind corporate TLS inspection), falling back to + the certifi bundle. + """ + return ( + os.environ.get("SSL_CERT_FILE") + or os.environ.get("REQUESTS_CA_BUNDLE") + or certifi.where() + ) diff --git a/massive/rest/__init__.py b/massive/rest/__init__.py index 5a00da5a..475922ff 100644 --- a/massive/rest/__init__.py +++ b/massive/rest/__init__.py @@ -20,7 +20,7 @@ ContractsClient, ) from .vX import VXClient -from typing import Optional, Any +from typing import Optional, Any, Dict import os BASE = "https://api.massive.com" @@ -60,7 +60,20 @@ def __init__( verbose: bool = False, trace: bool = False, custom_json: Optional[Any] = None, + maxsize: int = 10, + ca_certs: Optional[str] = None, + proxy: Optional[str] = None, + connection_pool_kw: Optional[Dict[str, Any]] = None, ): + """ + :param maxsize: Connections kept open per host, for reuse across threads. + :param ca_certs: Path to a CA bundle to verify TLS with. Defaults to the + SSL_CERT_FILE or REQUESTS_CA_BUNDLE environment variable, then certifi. + :param proxy: Proxy URL to send requests through, e.g. "http://user:pass@proxy:8080". + Environment proxy variables (HTTPS_PROXY etc.) are not used. + :param connection_pool_kw: Other urllib3 PoolManager options. They override + the client's defaults. + """ super().__init__( api_key=api_key, connect_timeout=connect_timeout, @@ -72,6 +85,10 @@ def __init__( verbose=verbose, trace=trace, custom_json=custom_json, + maxsize=maxsize, + ca_certs=ca_certs, + proxy=proxy, + connection_pool_kw=connection_pool_kw, ) self.vx = VXClient( api_key=api_key, @@ -84,4 +101,8 @@ def __init__( verbose=verbose, trace=trace, custom_json=custom_json, + maxsize=maxsize, + ca_certs=ca_certs, + proxy=proxy, + connection_pool_kw=connection_pool_kw, ) diff --git a/massive/rest/base.py b/massive/rest/base.py index ffff76a8..139577d3 100644 --- a/massive/rest/base.py +++ b/massive/rest/base.py @@ -1,4 +1,3 @@ -import certifi import json import urllib3 import inspect @@ -8,6 +7,7 @@ from datetime import datetime from importlib.metadata import version, PackageNotFoundError from .models.request import RequestOptionBuilder +from ..certs import ca_bundle_path from ..logging import get_logger import logging from urllib.parse import urlencode, urlparse @@ -34,6 +34,10 @@ def __init__( verbose: bool, trace: bool, custom_json: Optional[Any] = None, + maxsize: int = 10, + ca_certs: Optional[str] = None, + proxy: Optional[str] = None, + connection_pool_kw: Optional[Dict[str, Any]] = None, ): if api_key is None: raise AuthError( @@ -73,14 +77,30 @@ def __init__( # https://urllib3.readthedocs.io/en/stable/reference/urllib3.poolmanager.html # https://urllib3.readthedocs.io/en/stable/reference/urllib3.connectionpool.html#urllib3.HTTPConnectionPool - self.client = urllib3.PoolManager( - num_pools=num_pools, - headers=self.headers, # default headers sent with each request. - ca_certs=certifi.where(), - cert_reqs="CERT_REQUIRED", - retries=retry_strategy, # use the customized Retry instance - timeout=self.timeout, # set timeout for each request - ) + pool_kw: Dict[str, Any] = { + "num_pools": num_pools, + # connections kept per host; urllib3 defaults to 1, which makes threaded + # clients reconnect (and redo the TLS handshake) for most requests + "maxsize": maxsize, + "headers": self.headers, # default headers sent with each request. + "ca_certs": ca_certs or ca_bundle_path(), + "cert_reqs": "CERT_REQUIRED", + "retries": retry_strategy, # use the customized Retry instance + "timeout": self.timeout, # set timeout for each request + } + # any other urllib3 pool options, which take precedence over the defaults + pool_kw.update(connection_pool_kw or {}) + self.client: urllib3.PoolManager + if proxy: + # https://urllib3.readthedocs.io/en/stable/advanced-usage.html#proxies + proxy_auth = urllib3.util.parse_url(proxy).auth + if proxy_auth: + pool_kw.setdefault( + "proxy_headers", urllib3.make_headers(proxy_basic_auth=proxy_auth) + ) + self.client = urllib3.ProxyManager(proxy, **pool_kw) + else: + self.client = urllib3.PoolManager(**pool_kw) if verbose: logger.setLevel(logging.DEBUG) diff --git a/massive/websocket/__init__.py b/massive/websocket/__init__.py index 9b94d01a..8a88609e 100644 --- a/massive/websocket/__init__.py +++ b/massive/websocket/__init__.py @@ -7,10 +7,10 @@ import asyncio import ssl import time -import certifi from .models import * from websockets.asyncio.client import connect, ClientConnection from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError +from ..certs import ca_bundle_path from ..logging import get_logger import logging from ..exceptions import AuthError @@ -35,6 +35,7 @@ def __init__( max_reconnects: Optional[int] = 5, secure: bool = True, custom_json: Optional[Any] = None, + ca_certs: Optional[str] = None, **kwargs, ): """ @@ -47,6 +48,7 @@ def __init__( :param subscriptions: List of subscription parameters. :param max_reconnects: How many times to reconnect on network outage before ending .connect event loop. :param custom_json: Optional module exposing loads/dumps functions (similar to Python's json module) to be used for JSON conversions. + :param ca_certs: Path to a CA bundle to verify TLS with. Defaults to the SSL_CERT_FILE or REQUESTS_CA_BUNDLE environment variable, then certifi. :return: A client. """ if api_key is None: @@ -78,6 +80,7 @@ def __init__( self.schedule_resub = True self._server_errors: Counter = Counter() self._server_errors_since = 0.0 + self.ca_certs = ca_certs if custom_json: self.json = custom_json else: @@ -102,11 +105,7 @@ async def connect( """ reconnects = 0 logger.debug("connect: %s", self.url) - # darwin needs some extra <3 - ssl_context = None - if self.url.startswith("wss://"): - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ssl_context.load_verify_locations(certifi.where()) + ssl_context = self._ssl_context() last_exc = None async for s in connect( @@ -190,6 +189,14 @@ async def connect( ): raise last_exc + def _ssl_context(self) -> Optional[ssl.SSLContext]: + # darwin needs some extra <3 + if not self.url.startswith("wss://"): + return None + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.load_verify_locations(self.ca_certs or ca_bundle_path()) + return context + def run( self, handle_msg: Union[ diff --git a/test_rest/test_connection.py b/test_rest/test_connection.py new file mode 100644 index 00000000..7f7bcbe4 --- /dev/null +++ b/test_rest/test_connection.py @@ -0,0 +1,65 @@ +import os +import unittest +from unittest import mock + +import certifi +import urllib3 + +from massive import RESTClient + + +class ConnectionOptionsTest(unittest.TestCase): + def client(self, env=None, **kwargs): + with mock.patch.dict(os.environ, env or {}, clear=True): + return RESTClient(api_key="key", **kwargs) + + def test_ca_bundle_defaults_to_certifi(self): + c = self.client() + self.assertEqual(c.client.connection_pool_kw["ca_certs"], certifi.where()) + + def test_ca_bundle_from_env(self): + c = self.client({"REQUESTS_CA_BUNDLE": "/requests-ca.pem"}) + self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/requests-ca.pem") + c = self.client( + {"SSL_CERT_FILE": "/ssl-ca.pem", "REQUESTS_CA_BUNDLE": "/requests-ca.pem"} + ) + self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/ssl-ca.pem") + + def test_ca_certs_argument_wins_over_env(self): + c = self.client({"SSL_CERT_FILE": "/ssl-ca.pem"}, ca_certs="/explicit-ca.pem") + self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/explicit-ca.pem") + + def test_ca_certs_applies_to_vx(self): + c = self.client(ca_certs="/explicit-ca.pem") + self.assertEqual(c.vx.client.connection_pool_kw["ca_certs"], "/explicit-ca.pem") + + def test_maxsize(self): + self.assertEqual(self.client().client.connection_pool_kw["maxsize"], 10) + c = self.client(maxsize=32) + self.assertEqual(c.client.connection_pool_kw["maxsize"], 32) + + def test_connection_pool_kw_overrides_defaults(self): + timeout = urllib3.Timeout(connect=1, read=2) + c = self.client(connection_pool_kw={"block": True, "timeout": timeout}) + self.assertTrue(c.client.connection_pool_kw["block"]) + self.assertIs(c.client.connection_pool_kw["timeout"], timeout) + # defaults that weren't overridden are kept + self.assertEqual(c.client.connection_pool_kw["cert_reqs"], "CERT_REQUIRED") + + def test_no_proxy_by_default(self): + c = self.client({"HTTPS_PROXY": "http://proxy.example:8080"}) + self.assertNotIsInstance(c.client, urllib3.ProxyManager) + + def test_proxy(self): + c = self.client(proxy="http://user:secret@proxy.example:8080") + self.assertIsInstance(c.client, urllib3.ProxyManager) + self.assertEqual(c.client.proxy.host, "proxy.example") + self.assertEqual( + c.client.proxy_headers["proxy-authorization"], + urllib3.make_headers(proxy_basic_auth="user:secret")["proxy-authorization"], + ) + self.assertEqual(c.client.connection_pool_kw["maxsize"], 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_websocket/test_ssl.py b/test_websocket/test_ssl.py new file mode 100644 index 00000000..15196191 --- /dev/null +++ b/test_websocket/test_ssl.py @@ -0,0 +1,39 @@ +import os +import unittest +from unittest import mock + +import certifi + +from massive import WebSocketClient + + +class WebSocketSSLTest(unittest.TestCase): + def ca_path(self, env=None, **kwargs): + with mock.patch.dict(os.environ, env or {}, clear=True): + c = WebSocketClient(api_key="key", **kwargs) + with mock.patch("massive.websocket.ssl.SSLContext") as context: + c._ssl_context() + return context.return_value.load_verify_locations.call_args.args[0] + + def test_defaults_to_certifi(self): + self.assertEqual(self.ca_path(), certifi.where()) + + def test_env(self): + self.assertEqual(self.ca_path({"SSL_CERT_FILE": "/ssl-ca.pem"}), "/ssl-ca.pem") + self.assertEqual( + self.ca_path({"REQUESTS_CA_BUNDLE": "/requests-ca.pem"}), "/requests-ca.pem" + ) + + def test_ca_certs_argument_wins_over_env(self): + self.assertEqual( + self.ca_path({"SSL_CERT_FILE": "/ssl-ca.pem"}, ca_certs="/explicit-ca.pem"), + "/explicit-ca.pem", + ) + + def test_no_context_without_tls(self): + c = WebSocketClient(api_key="key", feed="localhost:8765", secure=False) + self.assertIsNone(c._ssl_context()) + + +if __name__ == "__main__": + unittest.main() From 122a731af654a0c479ae58d6a824941964180aec Mon Sep 17 00:00:00 2001 From: justinpolygon <123573436+justinpolygon@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:22:39 -0700 Subject: [PATCH 2/3] [SPT-1668] README: examples for custom CAs, proxies, threads, and pool options --- README.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 74da2f48..db372734 100644 --- a/README.md +++ b/README.md @@ -135,25 +135,98 @@ print(len(options_chain)) Also, please refer to the API documentation to get a full understanding of how the API works and the supported arguments. All required arguments are annotated with red asterisks " * " and argument examples are set. -## Proxies, Certificates, and Connections +## Network Configuration -Behind a corporate proxy or TLS inspection, point the clients at your CA bundle and proxy: +Options for corporate networks, proxies, and multithreaded use. They apply to both `RESTClient` and `WebSocketClient` unless noted. + +### Custom CA certificates (`certificate verify failed`) + +If you're behind a corporate firewall that inspects TLS traffic, requests can fail with `SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed`. Point the client at your company's CA bundle. + +**With environment variables (no code changes):** + +```bash +export SSL_CERT_FILE=/path/to/corporate-ca-bundle.pem +# or, if you already set this for the requests library: +export REQUESTS_CA_BUNDLE=/path/to/corporate-ca-bundle.pem +``` + +**In code:** ```python from massive import RESTClient, WebSocketClient +client = RESTClient(ca_certs="/path/to/corporate-ca-bundle.pem") +ws_client = WebSocketClient(ca_certs="/path/to/corporate-ca-bundle.pem") +``` + +The client uses the first one it finds: `ca_certs`, then `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then the bundled [certifi](https://pypi.org/project/certifi/) certificates. + +The bundle replaces certifi, so it must also include the public root certificates. If you only have your company's root certificate, add it to certifi's bundle: + +```bash +cat "$(python -m certifi)" corporate-root.pem > corporate-ca-bundle.pem +``` + +### Proxies + +**REST:** pass the proxy URL. `RESTClient` doesn't read `HTTPS_PROXY` on its own, so existing setups keep connecting the same way. + +```python +import os +from massive import RESTClient + +client = RESTClient(proxy="http://proxy.example.com:8080") + +# with a username and password +client = RESTClient(proxy="http://user:password@proxy.example.com:8080") + +# use the proxy from your environment, like the requests library does +client = RESTClient(proxy=os.environ.get("HTTPS_PROXY")) +``` + +**WebSocket:** `WebSocketClient` already uses the proxy from `HTTPS_PROXY` / `HTTP_PROXY` (and respects `NO_PROXY`). To set one explicitly, or to turn it off, pass `proxy` to `run()` or `connect()`: + +```python +ws_client = WebSocketClient(subscriptions=["T.AAPL"]) +ws_client.run(handle_msg, proxy="http://user:password@proxy.example.com:8080") +# ws_client.run(handle_msg, proxy=None) # connect directly, ignoring HTTPS_PROXY +``` + +**Behind a proxy that inspects TLS**, you'll usually need both: + +```python client = RESTClient( - proxy="http://user:pass@proxy.example.com:8080", # REST doesn't read HTTPS_PROXY - ca_certs="/path/to/corporate-ca.pem", # or set SSL_CERT_FILE / REQUESTS_CA_BUNDLE + proxy="http://proxy.example.com:8080", + ca_certs="/path/to/corporate-ca-bundle.pem", ) +``` + +### Using the REST client from multiple threads -ws_client = WebSocketClient(ca_certs="/path/to/corporate-ca.pem") -ws_client.run(handle_msg, proxy="http://user:pass@proxy.example.com:8080") # defaults to the system proxy +`RESTClient` can be shared across threads. It keeps up to `maxsize` connections (default 10) open for reuse, so set it to at least the number of threads making requests at the same time. Otherwise, extra threads open new connections and redo the TLS handshake on most requests. + +```python +from concurrent.futures import ThreadPoolExecutor +from massive import RESTClient + +client = RESTClient(maxsize=20) +tickers = ["AAPL", "MSFT", "NVDA", "AMZN"] + +with ThreadPoolExecutor(max_workers=20) as pool: + results = list(pool.map(lambda t: client.get_previous_close_agg(t), tickers)) ``` -Both clients verify TLS with `ca_certs` if given, then the `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` environment variable, then the bundled certifi certificates. +Note that `num_pools` doesn't help here: it's the number of different *hosts* the client keeps connections for, and the client only talks to one. -`RESTClient` keeps up to `maxsize=10` connections open for reuse across threads. Other urllib3 pool options can be passed with `connection_pool_kw`. +### Other connection options + +Any other [urllib3 pool option](https://urllib3.readthedocs.io/en/stable/reference/urllib3.connectionpool.html#urllib3.HTTPConnectionPool) can be passed with `connection_pool_kw`. These take precedence over the client's defaults, so avoid overriding `headers`, which carries your API key. + +```python +# wait for a free connection instead of opening extra ones beyond maxsize +client = RESTClient(maxsize=4, connection_pool_kw={"block": True}) +``` ## Debugging with RESTClient From e36245e4c20d8d860fdbc47c7dbc70e738d6da36 Mon Sep 17 00:00:00 2001 From: justinpolygon <123573436+justinpolygon@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:28:03 -0700 Subject: [PATCH 3/3] [SPT-1668] Skip CA bundle env vars that don't point to a file If SSL_CERT_FILE or REQUESTS_CA_BUNDLE is set to a path that doesn't exist, log a warning and fall back to the next option instead of failing, so a stale value doesn't break existing setups. An explicit ca_certs argument is still used as given. RESTClient resolves the path once so it warns once for both the main and vx clients. --- README.md | 2 +- massive/certs.py | 24 +++++++++++++++++------- massive/rest/__init__.py | 4 ++++ massive/rest/base.py | 2 +- massive/websocket/__init__.py | 2 +- test_rest/test_connection.py | 35 ++++++++++++++++++++++++++++++----- test_websocket/test_ssl.py | 21 ++++++++++++++++++--- 7 files changed, 72 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index db372734..32bf647a 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ client = RESTClient(ca_certs="/path/to/corporate-ca-bundle.pem") ws_client = WebSocketClient(ca_certs="/path/to/corporate-ca-bundle.pem") ``` -The client uses the first one it finds: `ca_certs`, then `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then the bundled [certifi](https://pypi.org/project/certifi/) certificates. +The client uses the first one it finds: `ca_certs`, then `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then the bundled [certifi](https://pypi.org/project/certifi/) certificates. If an environment variable points to a file that doesn't exist, it's skipped and a warning is logged. The bundle replaces certifi, so it must also include the public root certificates. If you only have your company's root certificate, add it to certifi's bundle: diff --git a/massive/certs.py b/massive/certs.py index 30b94ce4..4586527e 100644 --- a/massive/certs.py +++ b/massive/certs.py @@ -1,17 +1,27 @@ +import logging import os +from typing import Optional + import certifi -def ca_bundle_path() -> str: +def ca_bundle_path(logger: Optional[logging.Logger] = None) -> str: """ Path of the CA bundle used to verify TLS connections. Honors the standard ``SSL_CERT_FILE`` and ``REQUESTS_CA_BUNDLE`` environment variables (used for example behind corporate TLS inspection), falling back to - the certifi bundle. + the certifi bundle. A variable that doesn't point to an existing file is + skipped with a warning, so a stale value doesn't break connections. """ - return ( - os.environ.get("SSL_CERT_FILE") - or os.environ.get("REQUESTS_CA_BUNDLE") - or certifi.where() - ) + for env_var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"): + path = os.environ.get(env_var) + if not path: + continue + if os.path.isfile(path): + return path + if logger: + logger.warning( + "%s is set to %r, which isn't a file; ignoring it", env_var, path + ) + return certifi.where() diff --git a/massive/rest/__init__.py b/massive/rest/__init__.py index 475922ff..237ab84c 100644 --- a/massive/rest/__init__.py +++ b/massive/rest/__init__.py @@ -20,6 +20,8 @@ ContractsClient, ) from .vX import VXClient +from .base import logger +from ..certs import ca_bundle_path from typing import Optional, Any, Dict import os @@ -74,6 +76,8 @@ def __init__( :param connection_pool_kw: Other urllib3 PoolManager options. They override the client's defaults. """ + # resolve once, so the main and vx clients share it and warn only once + ca_certs = ca_certs or ca_bundle_path(logger) super().__init__( api_key=api_key, connect_timeout=connect_timeout, diff --git a/massive/rest/base.py b/massive/rest/base.py index 139577d3..294dd482 100644 --- a/massive/rest/base.py +++ b/massive/rest/base.py @@ -83,7 +83,7 @@ def __init__( # clients reconnect (and redo the TLS handshake) for most requests "maxsize": maxsize, "headers": self.headers, # default headers sent with each request. - "ca_certs": ca_certs or ca_bundle_path(), + "ca_certs": ca_certs or ca_bundle_path(logger), "cert_reqs": "CERT_REQUIRED", "retries": retry_strategy, # use the customized Retry instance "timeout": self.timeout, # set timeout for each request diff --git a/massive/websocket/__init__.py b/massive/websocket/__init__.py index 8a88609e..7463b4cb 100644 --- a/massive/websocket/__init__.py +++ b/massive/websocket/__init__.py @@ -194,7 +194,7 @@ def _ssl_context(self) -> Optional[ssl.SSLContext]: if not self.url.startswith("wss://"): return None context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - context.load_verify_locations(self.ca_certs or ca_bundle_path()) + context.load_verify_locations(self.ca_certs or ca_bundle_path(logger)) return context def run( diff --git a/test_rest/test_connection.py b/test_rest/test_connection.py index 7f7bcbe4..13f0f376 100644 --- a/test_rest/test_connection.py +++ b/test_rest/test_connection.py @@ -1,4 +1,5 @@ import os +import tempfile import unittest from unittest import mock @@ -9,6 +10,14 @@ class ConnectionOptionsTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.ssl_ca = os.path.join(tmp.name, "ssl-ca.pem") + self.requests_ca = os.path.join(tmp.name, "requests-ca.pem") + for path in (self.ssl_ca, self.requests_ca): + open(path, "w").close() + def client(self, env=None, **kwargs): with mock.patch.dict(os.environ, env or {}, clear=True): return RESTClient(api_key="key", **kwargs) @@ -18,15 +27,31 @@ def test_ca_bundle_defaults_to_certifi(self): self.assertEqual(c.client.connection_pool_kw["ca_certs"], certifi.where()) def test_ca_bundle_from_env(self): - c = self.client({"REQUESTS_CA_BUNDLE": "/requests-ca.pem"}) - self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/requests-ca.pem") + c = self.client({"REQUESTS_CA_BUNDLE": self.requests_ca}) + self.assertEqual(c.client.connection_pool_kw["ca_certs"], self.requests_ca) c = self.client( - {"SSL_CERT_FILE": "/ssl-ca.pem", "REQUESTS_CA_BUNDLE": "/requests-ca.pem"} + {"SSL_CERT_FILE": self.ssl_ca, "REQUESTS_CA_BUNDLE": self.requests_ca} ) - self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/ssl-ca.pem") + self.assertEqual(c.client.connection_pool_kw["ca_certs"], self.ssl_ca) + + def test_missing_env_bundle_is_skipped_with_a_warning(self): + with self.assertLogs("RESTClient", level="WARNING") as logs: + c = self.client({"SSL_CERT_FILE": "/does-not-exist.pem"}) + self.assertEqual(c.client.connection_pool_kw["ca_certs"], certifi.where()) + self.assertEqual(c.vx.client.connection_pool_kw["ca_certs"], certifi.where()) + self.assertEqual(len(logs.output), 1, logs.output) + self.assertIn("SSL_CERT_FILE", logs.output[0]) + with self.assertLogs("RESTClient", level="WARNING"): + c = self.client( + { + "SSL_CERT_FILE": "/does-not-exist.pem", + "REQUESTS_CA_BUNDLE": self.requests_ca, + } + ) + self.assertEqual(c.client.connection_pool_kw["ca_certs"], self.requests_ca) def test_ca_certs_argument_wins_over_env(self): - c = self.client({"SSL_CERT_FILE": "/ssl-ca.pem"}, ca_certs="/explicit-ca.pem") + c = self.client({"SSL_CERT_FILE": self.ssl_ca}, ca_certs="/explicit-ca.pem") self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/explicit-ca.pem") def test_ca_certs_applies_to_vx(self): diff --git a/test_websocket/test_ssl.py b/test_websocket/test_ssl.py index 15196191..0e101e9d 100644 --- a/test_websocket/test_ssl.py +++ b/test_websocket/test_ssl.py @@ -1,4 +1,5 @@ import os +import tempfile import unittest from unittest import mock @@ -8,6 +9,14 @@ class WebSocketSSLTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.ssl_ca = os.path.join(tmp.name, "ssl-ca.pem") + self.requests_ca = os.path.join(tmp.name, "requests-ca.pem") + for path in (self.ssl_ca, self.requests_ca): + open(path, "w").close() + def ca_path(self, env=None, **kwargs): with mock.patch.dict(os.environ, env or {}, clear=True): c = WebSocketClient(api_key="key", **kwargs) @@ -19,14 +28,20 @@ def test_defaults_to_certifi(self): self.assertEqual(self.ca_path(), certifi.where()) def test_env(self): - self.assertEqual(self.ca_path({"SSL_CERT_FILE": "/ssl-ca.pem"}), "/ssl-ca.pem") + self.assertEqual(self.ca_path({"SSL_CERT_FILE": self.ssl_ca}), self.ssl_ca) self.assertEqual( - self.ca_path({"REQUESTS_CA_BUNDLE": "/requests-ca.pem"}), "/requests-ca.pem" + self.ca_path({"REQUESTS_CA_BUNDLE": self.requests_ca}), self.requests_ca ) + def test_missing_env_bundle_is_skipped_with_a_warning(self): + with self.assertLogs("WebSocketClient", level="WARNING") as logs: + path = self.ca_path({"SSL_CERT_FILE": "/does-not-exist.pem"}) + self.assertEqual(path, certifi.where()) + self.assertIn("SSL_CERT_FILE", logs.output[0]) + def test_ca_certs_argument_wins_over_env(self): self.assertEqual( - self.ca_path({"SSL_CERT_FILE": "/ssl-ca.pem"}, ca_certs="/explicit-ca.pem"), + self.ca_path({"SSL_CERT_FILE": self.ssl_ca}, ca_certs="/explicit-ca.pem"), "/explicit-ca.pem", )