Compare commits

...

6 Commits

Author SHA1 Message Date
openhands
c18fe4282f Revert pyproject.toml and poetry.lock changes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-05 23:27:59 +00:00
openhands
42e9e441d9 Refactor github module to use lazy initialization for testability
- Replace module-level instantiation of TokenManager, GitHubDataCollector,
  and GithubManager with lazy getter function _get_github_manager()
- Add _get_webhook_secret() to read GITHUB_APP_WEBHOOK_SECRET at runtime
  instead of import time
- Add _is_webhooks_enabled() to check GITHUB_WEBHOOKS_ENABLED at runtime
- Move imports of external dependencies (integrations.models, etc.) inside
  functions where they are used
- Update tests to use direct imports instead of importlib.import_module()
- Update tests to mock the new getter functions instead of module variables
- Remove pytest-env configuration from pyproject.toml as it's no longer needed

This refactoring allows the github module to be imported without requiring
environment variables to be set, improving testability and eliminating the
need for pytest-env workarounds.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-05 23:25:52 +00:00
Rohit Malhotra
a2bab24e22 Merge branch 'main' into fix-github-webhook-client-disconnect-handling 2026-03-05 18:18:57 -05:00
openhands
6c56195785 Refactor tests to import and test actual github_events endpoint
- Remove fixture that recreated github_events function logic
- Import and test the real github_events endpoint from server.routes.integration.github
- Use @patch decorators to mock external dependencies (logger, verify_github_signature, etc.)
- Add pytest-env dependency to set required environment variables (GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET) needed for module import
- Add conftest.py with documentation about the test setup
- Use importlib for dynamic module loading to ensure proper test isolation

This addresses the code review feedback about testing actual code instead of a fixture copy.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-05 22:49:33 +00:00
openhands
7967662898 Remove test_client_disconnect_uses_debug_logging_not_warning test
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-05 22:25:16 +00:00
openhands
096d74acae Fix GitHub webhook ClientDisconnect handling
- Remove asyncio.wait_for timeout wrapper since FastAPI's endpoint timeout
  is shorter and triggers first, causing starlette.requests.ClientDisconnect
- Add explicit ClientDisconnect exception handler that returns 499 status
- Use logger.debug() instead of warning/exception for client disconnects
- Add comprehensive unit tests for ClientDisconnect handling

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-05 22:23:07 +00:00
3 changed files with 269 additions and 23 deletions

View File

@@ -1,27 +1,52 @@
import asyncio
import hashlib
import hmac
import os
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from integrations.github.data_collector import GitHubDataCollector
from integrations.github.github_manager import GithubManager
from integrations.models import Message, SourceType
from server.auth.constants import GITHUB_APP_WEBHOOK_SECRET
from server.auth.token_manager import TokenManager
from starlette.requests import ClientDisconnect
from openhands.core.logger import openhands_logger as logger
# Environment variable to disable GitHub webhooks
GITHUB_WEBHOOKS_ENABLED = os.environ.get('GITHUB_WEBHOOKS_ENABLED', '1') in (
'1',
'true',
)
github_integration_router = APIRouter(prefix='/integration')
token_manager = TokenManager()
data_collector = GitHubDataCollector()
github_manager = GithubManager(token_manager, data_collector)
# Lazy-initialized singleton for GitHub manager
_github_manager = None
def _get_github_manager():
"""Get the GitHub manager singleton, initializing it lazily if needed.
This lazy initialization pattern allows the module to be imported without
requiring environment variables to be set, which is useful for testing.
"""
global _github_manager
if _github_manager is None:
from integrations.github.data_collector import GitHubDataCollector
from integrations.github.github_manager import GithubManager
from server.auth.token_manager import TokenManager
token_manager = TokenManager()
data_collector = GitHubDataCollector()
_github_manager = GithubManager(token_manager, data_collector)
return _github_manager
def _get_webhook_secret() -> str:
"""Get the GitHub webhook secret from environment.
This function reads the secret at runtime rather than import time,
allowing the module to be imported without environment variables set.
"""
return os.environ.get('GITHUB_APP_WEBHOOK_SECRET', '')
def _is_webhooks_enabled() -> bool:
"""Check if GitHub webhooks are enabled.
Reads the environment variable at runtime for testability.
"""
return os.environ.get('GITHUB_WEBHOOKS_ENABLED', '1') in ('1', 'true')
def verify_github_signature(payload: bytes, signature: str):
@@ -30,10 +55,11 @@ def verify_github_signature(payload: bytes, signature: str):
status_code=403, detail='x-hub-signature-256 header is missing!'
)
webhook_secret = _get_webhook_secret()
expected_signature = (
'sha256='
+ hmac.new(
GITHUB_APP_WEBHOOK_SECRET.encode('utf-8'),
webhook_secret.encode('utf-8'),
msg=payload,
digestmod=hashlib.sha256,
).hexdigest()
@@ -49,7 +75,7 @@ async def github_events(
x_hub_signature_256: str = Header(None),
):
# Check if GitHub webhooks are enabled
if not GITHUB_WEBHOOKS_ENABLED:
if not _is_webhooks_enabled():
logger.info(
'GitHub webhooks are disabled by GITHUB_WEBHOOKS_ENABLED environment variable'
)
@@ -59,8 +85,7 @@ async def github_events(
)
try:
# Add timeout to prevent hanging on slow/stalled clients
payload = await asyncio.wait_for(request.body(), timeout=15.0)
payload = await request.body()
verify_github_signature(payload, x_hub_signature_256)
payload_data = await request.json()
@@ -72,19 +97,22 @@ async def github_events(
content={'error': 'Installation ID is missing in the payload.'},
)
# Import Message and SourceType lazily to avoid import-time dependencies
from integrations.models import Message, SourceType
message_payload = {'payload': payload_data, 'installation': installation_id}
message = Message(source=SourceType.GITHUB, message=message_payload)
await github_manager.receive_message(message)
await _get_github_manager().receive_message(message)
return JSONResponse(
status_code=200,
content={'message': 'GitHub events endpoint reached successfully.'},
)
except asyncio.TimeoutError:
logger.warning('GitHub webhook request timed out waiting for request body')
except ClientDisconnect:
logger.debug('GitHub webhook client disconnected before completing request')
return JSONResponse(
status_code=408,
content={'error': 'Request timeout - client took too long to send data.'},
status_code=499,
content={'error': 'Client disconnected.'},
)
except Exception as e:
logger.exception(f'Error processing GitHub event: {e}')

View File

@@ -0,0 +1,8 @@
"""Pytest configuration for server.routes tests.
This module sets up the test environment for server routes.
Note: The server.routes.integration.github module uses lazy initialization
for external dependencies (TokenManager, GithubManager, etc.), so it can be
imported directly without requiring environment variables to be set.
"""

View File

@@ -0,0 +1,210 @@
"""Unit tests for GitHub integration routes - ClientDisconnect handling.
These tests verify that ClientDisconnect exceptions are properly handled
when the FastAPI endpoint times out before the request body can be fully
received from the client.
These tests import and test the actual github_events endpoint from
server.routes.integration.github, mocking only external dependencies.
Note: The github module uses lazy initialization for external dependencies,
so it can be imported directly without requiring environment variables.
"""
import hashlib
import hmac
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import Request
from server.routes.integration.github import github_events
from starlette.requests import ClientDisconnect
@pytest.fixture
def mock_request():
"""Create a mock FastAPI Request object."""
req = MagicMock(spec=Request)
req.headers = {}
return req
def create_valid_signature(payload: bytes, secret: str = 'test-secret') -> str:
"""Create a valid HMAC signature for the given payload."""
signature = hmac.new(
secret.encode('utf-8'),
msg=payload,
digestmod=hashlib.sha256,
).hexdigest()
return f'sha256={signature}'
class TestClientDisconnect:
"""Test cases for ClientDisconnect handling in github_events endpoint."""
@pytest.mark.asyncio
@patch('server.routes.integration.github.logger')
@patch('server.routes.integration.github._is_webhooks_enabled', return_value=True)
async def test_client_disconnect_returns_499(
self, mock_webhooks_enabled, mock_logger, mock_request
):
"""Test that ClientDisconnect is caught and returns 499 status code.
This tests the scenario where the FastAPI endpoint times out before
the request body can be fully received, causing starlette to raise
ClientDisconnect.
"""
# Create a mock request that raises ClientDisconnect when body() is called
# This simulates what happens when the client disconnects or times out
mock_request.body = AsyncMock(side_effect=ClientDisconnect())
# Call the endpoint
response = await github_events(
request=mock_request,
x_hub_signature_256='sha256=test',
)
assert response.status_code == 499
assert response.body == b'{"error":"Client disconnected."}'
@pytest.mark.asyncio
@patch('server.routes.integration.github.logger')
@patch('server.routes.integration.github.verify_github_signature')
@patch('server.routes.integration.github._is_webhooks_enabled', return_value=True)
async def test_client_disconnect_during_json_parsing(
self, mock_webhooks_enabled, mock_verify_sig, mock_logger, mock_request
):
"""Test ClientDisconnect during request.json() call returns 499."""
payload = b'{"test": "data"}'
mock_request.body = AsyncMock(return_value=payload)
# ClientDisconnect can also happen during json parsing
mock_request.json = AsyncMock(side_effect=ClientDisconnect())
mock_verify_sig.return_value = None # Skip signature verification
response = await github_events(
request=mock_request,
x_hub_signature_256='sha256=test',
)
assert response.status_code == 499
assert response.body == b'{"error":"Client disconnected."}'
@pytest.mark.asyncio
@patch('server.routes.integration.github.logger')
@patch('server.routes.integration.github._is_webhooks_enabled', return_value=True)
async def test_client_disconnect_does_not_propagate_as_unhandled_exception(
self, mock_webhooks_enabled, mock_logger, mock_request
):
"""Test that ClientDisconnect doesn't cause unhandled exception logging."""
mock_request.body = AsyncMock(side_effect=ClientDisconnect())
# The function should return normally without raising
response = await github_events(
request=mock_request,
x_hub_signature_256='sha256=test',
)
# The generic exception handler should NOT be triggered
# (it uses logger.exception which includes 'Error processing GitHub event')
mock_logger.exception.assert_not_called()
assert response.status_code == 499
@pytest.mark.asyncio
@patch('server.routes.integration.github.logger')
@patch('server.routes.integration.github._is_webhooks_enabled', return_value=True)
async def test_client_disconnect_is_not_caught_by_generic_exception_handler(
self, mock_webhooks_enabled, mock_logger, mock_request
):
"""Test that ClientDisconnect is caught by its specific handler, not the generic one.
The generic exception handler returns 400 and logs with exception().
ClientDisconnect should return 499 and log with debug().
"""
mock_request.body = AsyncMock(side_effect=ClientDisconnect())
response = await github_events(
request=mock_request,
x_hub_signature_256='sha256=test',
)
# Should be 499 (ClientDisconnect), not 400 (generic exception)
assert response.status_code == 499
# Should use debug(), not exception()
mock_logger.debug.assert_called_once()
mock_logger.exception.assert_not_called()
class TestWebhooksDisabled:
"""Test cases for when webhooks are disabled."""
@pytest.mark.asyncio
@patch('server.routes.integration.github.logger')
@patch('server.routes.integration.github._is_webhooks_enabled', return_value=False)
async def test_webhooks_disabled_returns_200(
self, mock_webhooks_enabled, mock_logger, mock_request
):
"""Test that disabled webhooks return 200 with appropriate message."""
response = await github_events(
request=mock_request,
x_hub_signature_256='sha256=test',
)
assert response.status_code == 200
assert b'GitHub webhooks are currently disabled' in response.body
class TestSuccessfulRequest:
"""Test cases for successful webhook processing."""
@pytest.mark.asyncio
@patch('server.routes.integration.github._get_github_manager')
@patch('server.routes.integration.github.verify_github_signature')
@patch('server.routes.integration.github.logger')
@patch('server.routes.integration.github._is_webhooks_enabled', return_value=True)
async def test_successful_request_returns_200(
self,
mock_webhooks_enabled,
mock_logger,
mock_verify_sig,
mock_get_github_manager,
mock_request,
):
"""Test that a successful request returns 200."""
payload = b'{"installation": {"id": 123}}'
mock_request.body = AsyncMock(return_value=payload)
mock_request.json = AsyncMock(return_value={'installation': {'id': 123}})
mock_verify_sig.return_value = None
mock_github_manager = MagicMock()
mock_github_manager.receive_message = AsyncMock()
mock_get_github_manager.return_value = mock_github_manager
response = await github_events(
request=mock_request,
x_hub_signature_256='sha256=test',
)
assert response.status_code == 200
assert b'GitHub events endpoint reached successfully' in response.body
@pytest.mark.asyncio
@patch('server.routes.integration.github.verify_github_signature')
@patch('server.routes.integration.github.logger')
@patch('server.routes.integration.github._is_webhooks_enabled', return_value=True)
async def test_missing_installation_id_returns_400(
self, mock_webhooks_enabled, mock_logger, mock_verify_sig, mock_request
):
"""Test that missing installation ID returns 400."""
payload = b'{"action": "opened"}'
mock_request.body = AsyncMock(return_value=payload)
mock_request.json = AsyncMock(return_value={'action': 'opened'})
mock_verify_sig.return_value = None
response = await github_events(
request=mock_request,
x_hub_signature_256='sha256=test',
)
assert response.status_code == 400
assert b'Installation ID is missing' in response.body