Provide httpx default context for OS-provided certs (#11505)

Co-authored-by: Pierrick Hymbert <pierrick.hymbert@gmail.com>
This commit is contained in:
Ray Myers 2025-10-27 17:54:20 -05:00 committed by GitHub
parent 818f743dc7
commit 4decd8b3e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 93 additions and 37 deletions

View File

@ -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
)

View File

@ -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()

View File

@ -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,

View File

@ -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')

View File

@ -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'

View File

@ -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

View File

@ -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,

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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'

View File

@ -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={

View File

@ -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()

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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)