From 4decd8b3e960b4f547d4d36cf56e1f8c8a21e00c Mon Sep 17 00:00:00 2001 From: Ray Myers Date: Mon, 27 Oct 2025 17:54:20 -0500 Subject: [PATCH] Provide httpx default context for OS-provided certs (#11505) Co-authored-by: Pierrick Hymbert --- enterprise/integrations/jira/jira_manager.py | 5 ++-- .../integrations/jira_dc/jira_dc_manager.py | 5 ++-- .../integrations/linear/linear_manager.py | 3 +- enterprise/server/auth/token_manager.py | 9 +++--- enterprise/server/routes/api_keys.py | 7 +++-- enterprise/server/routes/billing.py | 5 ++-- enterprise/server/routes/github_proxy.py | 5 ++-- .../saas_nested_conversation_manager.py | 11 +++++-- enterprise/storage/saas_settings_store.py | 4 ++- .../integrations/bitbucket/service/base.py | 3 +- openhands/integrations/github/service/base.py | 5 ++-- openhands/integrations/gitlab/service/base.py | 5 ++-- openhands/integrations/provider.py | 3 +- openhands/resolver/interfaces/bitbucket.py | 3 +- openhands/runtime/impl/local/local_runtime.py | 3 +- .../docker_nested_conversation_manager.py | 13 ++++++--- openhands/storage/__init__.py | 6 +++- openhands/storage/batched_web_hook.py | 3 +- openhands/storage/web_hook.py | 3 +- openhands/utils/http_session.py | 29 +++++++++++++++++-- 20 files changed, 93 insertions(+), 37 deletions(-) diff --git a/enterprise/integrations/jira/jira_manager.py b/enterprise/integrations/jira/jira_manager.py index 7b0a335bcb..b8c7fecfc9 100644 --- a/enterprise/integrations/jira/jira_manager.py +++ b/enterprise/integrations/jira/jira_manager.py @@ -32,6 +32,7 @@ from openhands.integrations.service_types import Repository from openhands.server.shared import server_config from openhands.server.types import LLMAuthenticationError, MissingSettingsError from openhands.server.user_auth.user_auth import UserAuth +from openhands.utils.http_session import httpx_verify_option JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira' @@ -408,7 +409,7 @@ class JiraManager(Manager): svc_acc_api_key: str, ) -> Tuple[str, str]: url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key)) response.raise_for_status() issue_payload = response.json() @@ -443,7 +444,7 @@ class JiraManager(Manager): f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment' ) data = {'body': message.message} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post( url, auth=(svc_acc_email, svc_acc_api_key), json=data ) diff --git a/enterprise/integrations/jira_dc/jira_dc_manager.py b/enterprise/integrations/jira_dc/jira_dc_manager.py index 0267ec4e71..700267511b 100644 --- a/enterprise/integrations/jira_dc/jira_dc_manager.py +++ b/enterprise/integrations/jira_dc/jira_dc_manager.py @@ -34,6 +34,7 @@ from openhands.integrations.service_types import Repository from openhands.server.shared import server_config from openhands.server.types import LLMAuthenticationError, MissingSettingsError from openhands.server.user_auth.user_auth import UserAuth +from openhands.utils.http_session import httpx_verify_option class JiraDcManager(Manager): @@ -422,7 +423,7 @@ class JiraDcManager(Manager): """Get issue details from Jira DC API.""" url = f'{job_context.base_api_url}/rest/api/2/issue/{job_context.issue_key}' headers = {'Authorization': f'Bearer {svc_acc_api_key}'} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.get(url, headers=headers) response.raise_for_status() issue_payload = response.json() @@ -452,7 +453,7 @@ class JiraDcManager(Manager): url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment' headers = {'Authorization': f'Bearer {svc_acc_api_key}'} data = {'body': message.message} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, headers=headers, json=data) response.raise_for_status() return response.json() diff --git a/enterprise/integrations/linear/linear_manager.py b/enterprise/integrations/linear/linear_manager.py index 7a1b3933ac..5eed24d674 100644 --- a/enterprise/integrations/linear/linear_manager.py +++ b/enterprise/integrations/linear/linear_manager.py @@ -31,6 +31,7 @@ from openhands.integrations.service_types import Repository from openhands.server.shared import server_config from openhands.server.types import LLMAuthenticationError, MissingSettingsError from openhands.server.user_auth.user_auth import UserAuth +from openhands.utils.http_session import httpx_verify_option class LinearManager(Manager): @@ -408,7 +409,7 @@ class LinearManager(Manager): async def _query_api(self, query: str, variables: Dict, api_key: str) -> Dict: """Query Linear GraphQL API.""" headers = {'Authorization': api_key} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post( self.api_url, headers=headers, diff --git a/enterprise/server/auth/token_manager.py b/enterprise/server/auth/token_manager.py index 9e0eba0364..0b873bc7fc 100644 --- a/enterprise/server/auth/token_manager.py +++ b/enterprise/server/auth/token_manager.py @@ -37,6 +37,7 @@ from storage.offline_token_store import OfflineTokenStore from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt from openhands.integrations.service_types import ProviderType +from openhands.utils.http_session import httpx_verify_option def _before_sleep_callback(retry_state: RetryCallState) -> None: @@ -191,7 +192,7 @@ class TokenManager: access_token: str, idp: ProviderType, ) -> dict[str, str | int]: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token' headers = { @@ -350,7 +351,7 @@ class TokenManager: 'refresh_token': refresh_token, 'grant_type': 'refresh_token', } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, data=payload) response.raise_for_status() logger.info('Successfully refreshed GitHub token') @@ -376,7 +377,7 @@ class TokenManager: 'refresh_token': refresh_token, 'grant_type': 'refresh_token', } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, data=payload) response.raise_for_status() logger.info('Successfully refreshed GitLab token') @@ -404,7 +405,7 @@ class TokenManager: 'refresh_token': refresh_token, } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, data=data, headers=headers) response.raise_for_status() logger.info('Successfully refreshed Bitbucket token') diff --git a/enterprise/server/routes/api_keys.py b/enterprise/server/routes/api_keys.py index defa82c7d6..5cb6939217 100644 --- a/enterprise/server/routes/api_keys.py +++ b/enterprise/server/routes/api_keys.py @@ -12,6 +12,7 @@ from storage.saas_settings_store import SaasSettingsStore from openhands.core.logger import openhands_logger as logger from openhands.server.user_auth import get_user_id from openhands.utils.async_utils import call_sync_from_async +from openhands.utils.http_session import httpx_verify_option # Helper functions for BYOR API key management @@ -68,9 +69,10 @@ async def generate_byor_key(user_id: str) -> str | None: try: async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'x-goog-api-key': LITE_LLM_API_KEY, - } + }, ) as client: response = await client.post( f'{LITE_LLM_API_URL}/key/generate', @@ -120,9 +122,10 @@ async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool: try: async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'x-goog-api-key': LITE_LLM_API_KEY, - } + }, ) as client: # Delete the key directly using the key value delete_url = f'{LITE_LLM_API_URL}/key/delete' diff --git a/enterprise/server/routes/billing.py b/enterprise/server/routes/billing.py index 2ab046eeb0..5a8b59e2d7 100644 --- a/enterprise/server/routes/billing.py +++ b/enterprise/server/routes/billing.py @@ -27,6 +27,7 @@ from storage.saas_settings_store import SaasSettingsStore from storage.subscription_access import SubscriptionAccess from openhands.server.user_auth import get_user_id +from openhands.utils.http_session import httpx_verify_option stripe.api_key = STRIPE_API_KEY billing_router = APIRouter(prefix='/api/billing') @@ -110,7 +111,7 @@ def calculate_credits(user_info: LiteLlmUserInfo) -> float: async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse: if not stripe_service.STRIPE_API_KEY: return GetCreditsResponse() - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: user_json = await _get_litellm_user(client, user_id) credits = calculate_credits(user_json['user_info']) return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits))) @@ -430,7 +431,7 @@ async def success_callback(session_id: str, request: Request): ) raise HTTPException(status.HTTP_400_BAD_REQUEST) - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: # Update max budget in litellm user_json = await _get_litellm_user(client, billing_session.user_id) amount_subtotal = stripe_session.amount_subtotal or 0 diff --git a/enterprise/server/routes/github_proxy.py b/enterprise/server/routes/github_proxy.py index 14ba0bb8ce..d7f4452aa5 100644 --- a/enterprise/server/routes/github_proxy.py +++ b/enterprise/server/routes/github_proxy.py @@ -11,6 +11,7 @@ from fastapi.responses import RedirectResponse from server.logger import logger from openhands.server.shared import config +from openhands.utils.http_session import httpx_verify_option GITHUB_PROXY_ENDPOINTS = bool(os.environ.get('GITHUB_PROXY_ENDPOINTS')) @@ -87,7 +88,7 @@ def add_github_proxy_routes(app: FastAPI): ] body = urlencode(query_params, doseq=True) url = 'https://github.com/login/oauth/access_token' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, content=body) return Response( response.content, @@ -101,7 +102,7 @@ def add_github_proxy_routes(app: FastAPI): logger.info(f'github_proxy_post:1:{path}') body = await request.body() url = f'https://github.com/{path}' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, content=body, headers=request.headers) return Response( response.content, diff --git a/enterprise/server/saas_nested_conversation_manager.py b/enterprise/server/saas_nested_conversation_manager.py index 6eb03a66e3..e0727996de 100644 --- a/enterprise/server/saas_nested_conversation_manager.py +++ b/enterprise/server/saas_nested_conversation_manager.py @@ -52,6 +52,7 @@ from openhands.storage.locations import ( get_conversation_events_dir, ) from openhands.utils.async_utils import call_sync_from_async +from openhands.utils.http_session import httpx_verify_option from openhands.utils.import_utils import get_impl from openhands.utils.shutdown_listener import should_continue from openhands.utils.utils import create_registry_and_conversation_stats @@ -266,9 +267,10 @@ class SaasNestedConversationManager(ConversationManager): ): logger.info('starting_nested_conversation', extra={'sid': sid}) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': session_api_key, - } + }, ) as client: await self._setup_nested_settings(client, api_url, settings) await self._setup_provider_tokens(client, api_url, settings) @@ -484,9 +486,10 @@ class SaasNestedConversationManager(ConversationManager): raise ValueError(f'no_such_conversation:{sid}') nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': runtime['session_api_key'], - } + }, ) as client: response = await client.post(f'{nested_url}/events', json=data) response.raise_for_status() @@ -551,9 +554,10 @@ class SaasNestedConversationManager(ConversationManager): return None async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': session_api_key, - } + }, ) as client: # Query the nested runtime for conversation info response = await client.get(nested_url) @@ -828,6 +832,7 @@ class SaasNestedConversationManager(ConversationManager): @contextlib.asynccontextmanager async def _httpx_client(self): async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={'X-API-Key': self.config.sandbox.api_key or ''}, timeout=_HTTP_TIMEOUT, ) as client: diff --git a/enterprise/storage/saas_settings_store.py b/enterprise/storage/saas_settings_store.py index 719a45c49d..bf27c4aaa5 100644 --- a/enterprise/storage/saas_settings_store.py +++ b/enterprise/storage/saas_settings_store.py @@ -31,6 +31,7 @@ from openhands.server.settings import Settings from openhands.storage import get_file_store from openhands.storage.settings.settings_store import SettingsStore from openhands.utils.async_utils import call_sync_from_async +from openhands.utils.http_session import httpx_verify_option @dataclass @@ -215,9 +216,10 @@ class SaasSettingsStore(SettingsStore): ) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'x-goog-api-key': LITE_LLM_API_KEY, - } + }, ) as client: # Get the previous max budget to prevent accidental loss # In Litellm a get always succeeds, regardless of whether the user actually exists diff --git a/openhands/integrations/bitbucket/service/base.py b/openhands/integrations/bitbucket/service/base.py index d7c9b4adf7..78c27cf061 100644 --- a/openhands/integrations/bitbucket/service/base.py +++ b/openhands/integrations/bitbucket/service/base.py @@ -14,6 +14,7 @@ from openhands.integrations.service_types import ( ResourceNotFoundError, User, ) +from openhands.utils.http_session import httpx_verify_option class BitBucketMixinBase(BaseGitService, HTTPClient): @@ -83,7 +84,7 @@ class BitBucketMixinBase(BaseGitService, HTTPClient): """ try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: bitbucket_headers = await self._get_headers() response = await self.execute_request( client, url, bitbucket_headers, params, method diff --git a/openhands/integrations/github/service/base.py b/openhands/integrations/github/service/base.py index 556c647390..7646249fbe 100644 --- a/openhands/integrations/github/service/base.py +++ b/openhands/integrations/github/service/base.py @@ -11,6 +11,7 @@ from openhands.integrations.service_types import ( UnknownException, User, ) +from openhands.utils.http_session import httpx_verify_option class GitHubMixinBase(BaseGitService, HTTPClient): @@ -43,7 +44,7 @@ class GitHubMixinBase(BaseGitService, HTTPClient): method: RequestMethod = RequestMethod.GET, ) -> tuple[Any, dict]: # type: ignore[override] try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: github_headers = await self._get_headers() # Make initial request @@ -83,7 +84,7 @@ class GitHubMixinBase(BaseGitService, HTTPClient): self, query: str, variables: dict[str, Any] ) -> dict[str, Any]: try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: github_headers = await self._get_headers() response = await client.post( diff --git a/openhands/integrations/gitlab/service/base.py b/openhands/integrations/gitlab/service/base.py index 239d972720..fca1171942 100644 --- a/openhands/integrations/gitlab/service/base.py +++ b/openhands/integrations/gitlab/service/base.py @@ -10,6 +10,7 @@ from openhands.integrations.service_types import ( UnknownException, User, ) +from openhands.utils.http_session import httpx_verify_option class GitLabMixinBase(BaseGitService, HTTPClient): @@ -41,7 +42,7 @@ class GitLabMixinBase(BaseGitService, HTTPClient): method: RequestMethod = RequestMethod.GET, ) -> tuple[Any, dict]: # type: ignore[override] try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: gitlab_headers = await self._get_headers() # Make initial request @@ -99,7 +100,7 @@ class GitLabMixinBase(BaseGitService, HTTPClient): if variables is None: variables = {} try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: gitlab_headers = await self._get_headers() # Add content type header for GraphQL gitlab_headers['Content-Type'] = 'application/json' diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index 7e68b95623..09c1ae7e11 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -36,6 +36,7 @@ from openhands.integrations.service_types import ( ) from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse from openhands.server.types import AppMode +from openhands.utils.http_session import httpx_verify_option class ProviderToken(BaseModel): @@ -174,7 +175,7 @@ class ProviderHandler: ) -> SecretStr | None: """Get latest token from service""" try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: resp = await client.get( self.REFRESH_TOKEN_URL, headers={ diff --git a/openhands/resolver/interfaces/bitbucket.py b/openhands/resolver/interfaces/bitbucket.py index 4baaed7750..3799f7f6bc 100644 --- a/openhands/resolver/interfaces/bitbucket.py +++ b/openhands/resolver/interfaces/bitbucket.py @@ -10,6 +10,7 @@ from openhands.resolver.interfaces.issue import ( ReviewThread, ) from openhands.resolver.utils import extract_issue_references +from openhands.utils.http_session import httpx_verify_option class BitbucketIssueHandler(IssueHandlerInterface): @@ -91,7 +92,7 @@ class BitbucketIssueHandler(IssueHandlerInterface): An Issue object """ url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/issues/{issue_number}' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.get(url, headers=self.headers) response.raise_for_status() data = response.json() diff --git a/openhands/runtime/impl/local/local_runtime.py b/openhands/runtime/impl/local/local_runtime.py index 01df02dfe6..ed8d26996a 100644 --- a/openhands/runtime/impl/local/local_runtime.py +++ b/openhands/runtime/impl/local/local_runtime.py @@ -42,6 +42,7 @@ from openhands.runtime.runtime_status import RuntimeStatus from openhands.runtime.utils import find_available_tcp_port from openhands.runtime.utils.command import get_action_execution_server_startup_command from openhands.utils.async_utils import call_sync_from_async +from openhands.utils.http_session import httpx_verify_option from openhands.utils.tenacity_stop import stop_if_should_exit @@ -760,7 +761,7 @@ def _create_warm_server( ) # Wait for the server to be ready - session = httpx.Client(timeout=30) + session = httpx.Client(timeout=30, verify=httpx_verify_option()) # Use tenacity to retry the connection @tenacity.retry( diff --git a/openhands/server/conversation_manager/docker_nested_conversation_manager.py b/openhands/server/conversation_manager/docker_nested_conversation_manager.py index fce8e90a25..81aa4b4bea 100644 --- a/openhands/server/conversation_manager/docker_nested_conversation_manager.py +++ b/openhands/server/conversation_manager/docker_nested_conversation_manager.py @@ -42,6 +42,7 @@ from openhands.storage.data_models.settings import Settings from openhands.storage.files import FileStore from openhands.storage.locations import get_conversation_dir from openhands.utils.async_utils import call_sync_from_async +from openhands.utils.http_session import httpx_verify_option from openhands.utils.import_utils import get_impl from openhands.utils.utils import create_registry_and_conversation_stats @@ -200,9 +201,10 @@ class DockerNestedConversationManager(ConversationManager): await call_sync_from_async(runtime.wait_until_alive) await call_sync_from_async(runtime.setup_initial_env) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': self._get_session_api_key_for_conversation(sid) - } + }, ) as client: # setup the settings... settings_json = settings.model_dump(context={'expose_secrets': True}) @@ -296,9 +298,10 @@ class DockerNestedConversationManager(ConversationManager): async def send_event_to_conversation(self, sid, data): async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': self._get_session_api_key_for_conversation(sid) - } + }, ) as client: nested_url = self._get_nested_url(sid) response = await client.post( @@ -319,9 +322,10 @@ class DockerNestedConversationManager(ConversationManager): try: nested_url = self.get_nested_url_for_container(container) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': self._get_session_api_key_for_conversation(sid) - } + }, ) as client: # Stop conversation response = await client.post( @@ -357,11 +361,12 @@ class DockerNestedConversationManager(ConversationManager): """ try: async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': self._get_session_api_key_for_conversation( conversation_id ) - } + }, ) as client: # Query the nested runtime for conversation info response = await client.get(nested_url) diff --git a/openhands/storage/__init__.py b/openhands/storage/__init__.py index 5d8a744b24..8ac6f47a9e 100644 --- a/openhands/storage/__init__.py +++ b/openhands/storage/__init__.py @@ -9,6 +9,7 @@ from openhands.storage.local import LocalFileStore from openhands.storage.memory import InMemoryFileStore from openhands.storage.s3 import S3FileStore from openhands.storage.web_hook import WebHookFileStore +from openhands.utils.http_session import httpx_verify_option def get_file_store( @@ -38,7 +39,10 @@ def get_file_store( 'SESSION_API_KEY' ) - client = httpx.Client(headers=file_store_web_hook_headers or {}) + client = httpx.Client( + headers=file_store_web_hook_headers or {}, + verify=httpx_verify_option(), + ) if file_store_web_hook_batch: # Use batched webhook file store diff --git a/openhands/storage/batched_web_hook.py b/openhands/storage/batched_web_hook.py index 6a9495d5e0..7cd6d17fc4 100644 --- a/openhands/storage/batched_web_hook.py +++ b/openhands/storage/batched_web_hook.py @@ -7,6 +7,7 @@ import tenacity from openhands.core.logger import openhands_logger as logger from openhands.storage.files import FileStore from openhands.utils.async_utils import EXECUTOR +from openhands.utils.http_session import httpx_verify_option # Constants for batching configuration WEBHOOK_BATCH_TIMEOUT_SECONDS = 5.0 @@ -65,7 +66,7 @@ class BatchedWebHookFileStore(FileStore): self.file_store = file_store self.base_url = base_url if client is None: - client = httpx.Client() + client = httpx.Client(verify=httpx_verify_option()) self.client = client # Use provided values or default constants diff --git a/openhands/storage/web_hook.py b/openhands/storage/web_hook.py index 71f7c73edd..d41ef0b93b 100644 --- a/openhands/storage/web_hook.py +++ b/openhands/storage/web_hook.py @@ -3,6 +3,7 @@ import tenacity from openhands.storage.files import FileStore from openhands.utils.async_utils import EXECUTOR +from openhands.utils.http_session import httpx_verify_option class WebHookFileStore(FileStore): @@ -34,7 +35,7 @@ class WebHookFileStore(FileStore): self.file_store = file_store self.base_url = base_url if client is None: - client = httpx.Client() + client = httpx.Client(verify=httpx_verify_option()) self.client = client def write(self, path: str, contents: str | bytes) -> None: diff --git a/openhands/utils/http_session.py b/openhands/utils/http_session.py index 3397b8ce46..8932340951 100644 --- a/openhands/utils/http_session.py +++ b/openhands/utils/http_session.py @@ -1,11 +1,34 @@ +import ssl from dataclasses import dataclass, field +from threading import Lock from typing import MutableMapping import httpx from openhands.core.logger import openhands_logger as logger -CLIENT = httpx.Client() +_client_lock = Lock() +_verify_certificates: bool = True +_client: httpx.Client | None = None + + +def httpx_verify_option() -> ssl.SSLContext | bool: + """Return the verify option to pass when creating an HTTPX client.""" + + return ssl.create_default_context() if _verify_certificates else False + + +def _build_client(verify: bool) -> httpx.Client: + return httpx.Client(verify=ssl.create_default_context() if verify else False) + + +def _get_client() -> httpx.Client: + global _client + if _client is None: + with _client_lock: + if _client is None: + _client = _build_client(_verify_certificates) + return _client @dataclass @@ -28,7 +51,7 @@ class HttpSession: headers = {**self.headers, **headers} kwargs['headers'] = headers logger.debug(f'HttpSession:request called with args {args} and kwargs {kwargs}') - return CLIENT.request(*args, **kwargs) + return _get_client().request(*args, **kwargs) def stream(self, *args, **kwargs): if self._is_closed: @@ -39,7 +62,7 @@ class HttpSession: headers = kwargs.get('headers') or {} headers = {**self.headers, **headers} kwargs['headers'] = headers - return CLIENT.stream(*args, **kwargs) + return _get_client().stream(*args, **kwargs) def get(self, *args, **kwargs): return self.request('GET', *args, **kwargs)