Skip to content

Commit 20a302c

Browse files
rhamzehwadells
andauthored
fix: reuse ssl context in the sync client (#222)
* fix: reuse ssl context in the sync client This brings ssl context handling in line with the async client. Importantly, openssl has a pretty signifigant performance regression in creating ssl contexts v3.0+ that is mitigated by paying the context creation tax once, instead of for every request. Based on testing, this reduces the openssl v3 performance penalty from ~200ms per connection to 9ms per connection. Original PR: openfga/sdk-generator#607 * chore: add tests for ssl context reuse --------- Co-authored-by: Walt Della <walt.della@zapier.com>
1 parent 89a39d1 commit 20a302c

File tree

2 files changed

+217
-13
lines changed

2 files changed

+217
-13
lines changed

openfga_sdk/sync/rest.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,18 @@ def __init__(
155155
:param pools_size: The number of connection pools to use.
156156
:param maxsize: The maximum number of connections per pool.
157157
"""
158-
if hasattr(configuration, "verify_ssl") and configuration.verify_ssl:
159-
cert_reqs = ssl.CERT_REQUIRED
160-
else:
161-
cert_reqs = ssl.CERT_NONE
158+
# Reuse SSL context to mitigate OpenSSL 3.0+ performance issues
159+
# See: https://github.com/openssl/openssl/issues/17064
160+
ssl_context = ssl.create_default_context(cafile=configuration.ssl_ca_cert)
161+
162+
if configuration.cert_file:
163+
ssl_context.load_cert_chain(
164+
configuration.cert_file, keyfile=configuration.key_file
165+
)
166+
167+
if not configuration.verify_ssl:
168+
ssl_context.check_hostname = False
169+
ssl_context.verify_mode = ssl.CERT_NONE
162170

163171
addition_pool_args = {}
164172

@@ -193,10 +201,7 @@ def __init__(
193201
urllib3.ProxyManager(
194202
num_pools=pools_size,
195203
maxsize=maxsize,
196-
cert_reqs=cert_reqs,
197-
ca_certs=configuration.ssl_ca_cert,
198-
cert_file=configuration.cert_file,
199-
key_file=configuration.key_file,
204+
ssl_context=ssl_context,
200205
proxy_url=configuration.proxy,
201206
proxy_headers=configuration.proxy_headers,
202207
**addition_pool_args,
@@ -208,10 +213,7 @@ def __init__(
208213
self.pool_manager = urllib3.PoolManager(
209214
num_pools=pools_size,
210215
maxsize=maxsize,
211-
cert_reqs=cert_reqs,
212-
ca_certs=configuration.ssl_ca_cert,
213-
cert_file=configuration.cert_file,
214-
key_file=configuration.key_file,
216+
ssl_context=ssl_context,
215217
**addition_pool_args,
216218
)
217219

test/sync/rest_test.py

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
"""
1212

1313
import json
14+
import ssl
1415

15-
from unittest.mock import MagicMock
16+
from unittest.mock import MagicMock, patch
1617

1718
import pytest
1819

@@ -531,3 +532,204 @@ def release_conn(self):
531532
# Exception is logged, we yield nothing
532533
assert results == []
533534
mock_pool_manager.request.assert_called_once()
535+
536+
537+
# Tests for SSL Context Reuse (fix for OpenSSL 3.0+ performance issues)
538+
@patch('ssl.create_default_context')
539+
@patch('urllib3.PoolManager')
540+
def test_ssl_context_created_with_ca_cert(mock_pool_manager, mock_create_context):
541+
"""Test that SSL context is created with CA certificate file."""
542+
mock_ssl_context = MagicMock()
543+
mock_create_context.return_value = mock_ssl_context
544+
545+
mock_config = MagicMock()
546+
mock_config.ssl_ca_cert = "/path/to/ca.pem"
547+
mock_config.cert_file = None
548+
mock_config.key_file = None
549+
mock_config.verify_ssl = True
550+
mock_config.connection_pool_maxsize = 4
551+
mock_config.timeout_millisec = 5000
552+
mock_config.proxy = None
553+
554+
RESTClientObject(configuration=mock_config)
555+
556+
# Verify SSL context was created with CA file
557+
mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem")
558+
559+
# Verify SSL context was passed to PoolManager
560+
mock_pool_manager.assert_called_once()
561+
call_kwargs = mock_pool_manager.call_args[1]
562+
assert call_kwargs['ssl_context'] == mock_ssl_context
563+
564+
565+
@patch('ssl.create_default_context')
566+
@patch('urllib3.PoolManager')
567+
def test_ssl_context_loads_client_certificate(mock_pool_manager, mock_create_context):
568+
"""Test that SSL context loads client certificate and key when provided."""
569+
mock_ssl_context = MagicMock()
570+
mock_create_context.return_value = mock_ssl_context
571+
572+
mock_config = MagicMock()
573+
mock_config.ssl_ca_cert = None
574+
mock_config.cert_file = "/path/to/client.pem"
575+
mock_config.key_file = "/path/to/client.key"
576+
mock_config.verify_ssl = True
577+
mock_config.connection_pool_maxsize = 4
578+
mock_config.timeout_millisec = 5000
579+
mock_config.proxy = None
580+
581+
RESTClientObject(configuration=mock_config)
582+
583+
# Verify SSL context was created
584+
mock_create_context.assert_called_once_with(cafile=None)
585+
586+
# Verify client certificate was loaded
587+
mock_ssl_context.load_cert_chain.assert_called_once_with(
588+
"/path/to/client.pem", keyfile="/path/to/client.key"
589+
)
590+
591+
# Verify SSL context was passed to PoolManager
592+
mock_pool_manager.assert_called_once()
593+
call_kwargs = mock_pool_manager.call_args[1]
594+
assert call_kwargs['ssl_context'] == mock_ssl_context
595+
596+
597+
@patch('ssl.create_default_context')
598+
@patch('urllib3.PoolManager')
599+
def test_ssl_context_disables_verification_when_verify_ssl_false(mock_pool_manager, mock_create_context):
600+
"""Test that SSL context disables verification when verify_ssl=False."""
601+
mock_ssl_context = MagicMock()
602+
mock_create_context.return_value = mock_ssl_context
603+
604+
mock_config = MagicMock()
605+
mock_config.ssl_ca_cert = None
606+
mock_config.cert_file = None
607+
mock_config.key_file = None
608+
mock_config.verify_ssl = False
609+
mock_config.connection_pool_maxsize = 4
610+
mock_config.timeout_millisec = 5000
611+
mock_config.proxy = None
612+
613+
RESTClientObject(configuration=mock_config)
614+
615+
# Verify SSL context was created
616+
mock_create_context.assert_called_once_with(cafile=None)
617+
618+
# Verify SSL verification was disabled
619+
assert mock_ssl_context.check_hostname is False
620+
assert mock_ssl_context.verify_mode == ssl.CERT_NONE
621+
622+
# Verify SSL context was passed to PoolManager
623+
mock_pool_manager.assert_called_once()
624+
call_kwargs = mock_pool_manager.call_args[1]
625+
assert call_kwargs['ssl_context'] == mock_ssl_context
626+
627+
628+
@patch('ssl.create_default_context')
629+
@patch('urllib3.ProxyManager')
630+
def test_ssl_context_used_with_proxy_manager(mock_proxy_manager, mock_create_context):
631+
"""Test that SSL context is passed to ProxyManager when proxy is configured."""
632+
mock_ssl_context = MagicMock()
633+
mock_create_context.return_value = mock_ssl_context
634+
635+
mock_config = MagicMock()
636+
mock_config.ssl_ca_cert = "/path/to/ca.pem"
637+
mock_config.cert_file = "/path/to/client.pem"
638+
mock_config.key_file = "/path/to/client.key"
639+
mock_config.verify_ssl = True
640+
mock_config.connection_pool_maxsize = 4
641+
mock_config.timeout_millisec = 5000
642+
mock_config.proxy = "http://proxy:8080"
643+
mock_config.proxy_headers = {"Proxy-Auth": "token"}
644+
645+
RESTClientObject(configuration=mock_config)
646+
647+
# Verify SSL context was created with CA file
648+
mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem")
649+
650+
# Verify client certificate was loaded
651+
mock_ssl_context.load_cert_chain.assert_called_once_with(
652+
"/path/to/client.pem", keyfile="/path/to/client.key"
653+
)
654+
655+
# Verify SSL context was passed to ProxyManager
656+
mock_proxy_manager.assert_called_once()
657+
call_kwargs = mock_proxy_manager.call_args[1]
658+
assert call_kwargs['ssl_context'] == mock_ssl_context
659+
assert call_kwargs['proxy_url'] == "http://proxy:8080"
660+
assert call_kwargs['proxy_headers'] == {"Proxy-Auth": "token"}
661+
662+
663+
@patch('ssl.create_default_context')
664+
@patch('urllib3.PoolManager')
665+
def test_ssl_context_reuse_performance_optimization(mock_pool_manager, mock_create_context):
666+
"""Test that SSL context creation is called only once per client instance."""
667+
mock_ssl_context = MagicMock()
668+
mock_create_context.return_value = mock_ssl_context
669+
670+
mock_config = MagicMock()
671+
mock_config.ssl_ca_cert = "/path/to/ca.pem"
672+
mock_config.cert_file = None
673+
mock_config.key_file = None
674+
mock_config.verify_ssl = True
675+
mock_config.connection_pool_maxsize = 4
676+
mock_config.timeout_millisec = 5000
677+
mock_config.proxy = None
678+
679+
# Create client instance
680+
client = RESTClientObject(configuration=mock_config)
681+
682+
# Verify SSL context was created exactly once
683+
mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem")
684+
685+
# Verify the same SSL context instance is reused
686+
mock_pool_manager.assert_called_once()
687+
call_kwargs = mock_pool_manager.call_args[1]
688+
assert call_kwargs['ssl_context'] is mock_ssl_context
689+
690+
# Verify context was not created again during subsequent operations
691+
mock_create_context.reset_mock()
692+
693+
# Build a request (this should not trigger SSL context creation)
694+
client.build_request("GET", "https://example.com")
695+
696+
# SSL context should not be created again
697+
mock_create_context.assert_not_called()
698+
699+
700+
@patch('ssl.create_default_context')
701+
@patch('urllib3.PoolManager')
702+
def test_ssl_context_with_all_ssl_options(mock_pool_manager, mock_create_context):
703+
"""Test SSL context creation with all SSL configuration options set."""
704+
mock_ssl_context = MagicMock()
705+
mock_create_context.return_value = mock_ssl_context
706+
707+
mock_config = MagicMock()
708+
mock_config.ssl_ca_cert = "/path/to/ca.pem"
709+
mock_config.cert_file = "/path/to/client.pem"
710+
mock_config.key_file = "/path/to/client.key"
711+
mock_config.verify_ssl = True
712+
mock_config.connection_pool_maxsize = 8
713+
mock_config.timeout_millisec = 10000
714+
mock_config.proxy = None
715+
716+
RESTClientObject(configuration=mock_config)
717+
718+
# Verify SSL context was created with CA file
719+
mock_create_context.assert_called_once_with(cafile="/path/to/ca.pem")
720+
721+
# Verify client certificate was loaded
722+
mock_ssl_context.load_cert_chain.assert_called_once_with(
723+
"/path/to/client.pem", keyfile="/path/to/client.key"
724+
)
725+
726+
# Verify SSL verification settings were NOT modified (verify_ssl=True)
727+
# check_hostname and verify_mode should remain at their default secure values
728+
assert not hasattr(mock_ssl_context, 'check_hostname') or mock_ssl_context.check_hostname
729+
assert not hasattr(mock_ssl_context, 'verify_mode') or mock_ssl_context.verify_mode != ssl.CERT_NONE
730+
731+
# Verify SSL context was passed to PoolManager
732+
mock_pool_manager.assert_called_once()
733+
call_kwargs = mock_pool_manager.call_args[1]
734+
assert call_kwargs['ssl_context'] == mock_ssl_context
735+
assert call_kwargs['maxsize'] == 8

0 commit comments

Comments
 (0)