mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 17:08:34 +08:00
fix(jira): make Jira Cloud and Jira DC HTTP timeouts configurable and consistent (#15012)
This commit is contained in:
@@ -36,6 +36,7 @@ from integrations.utils import (
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.constants import JIRA_HTTP_TIMEOUT
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.jira_integration_store import JiraIntegrationStore
|
||||
@@ -343,7 +344,9 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
|
||||
)
|
||||
data = format_jira_comment_body(message)
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(
|
||||
url, auth=(svc_acc_email, svc_acc_api_key), json=data
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from uuid import UUID
|
||||
import httpx
|
||||
from integrations.utils import format_jira_comment_body, get_summary_instruction
|
||||
from pydantic import Field
|
||||
from server.auth.constants import JIRA_HTTP_TIMEOUT
|
||||
|
||||
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
|
||||
from openhands.app_server.event_callback.event_callback_models import (
|
||||
@@ -173,7 +174,7 @@ class JiraV1CallbackProcessor(EventCallbackProcessor):
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
timeout=JIRA_HTTP_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -201,7 +202,9 @@ class JiraV1CallbackProcessor(EventCallbackProcessor):
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_detail = f'Request timeout after 30 seconds to {url}'
|
||||
error_detail = (
|
||||
f'Request timeout after {JIRA_HTTP_TIMEOUT:g} seconds to {url}'
|
||||
)
|
||||
_logger.exception(
|
||||
'[Jira] Timeout error: %s. Request payload: %s',
|
||||
error_detail,
|
||||
@@ -232,7 +235,9 @@ class JiraV1CallbackProcessor(EventCallbackProcessor):
|
||||
message = f'OpenHands resolved this issue:\n\n{summary}'
|
||||
comment_body = format_jira_comment_body(message)
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(
|
||||
comment_url,
|
||||
auth=(self.svc_acc_email, self.decrypted_api_key),
|
||||
|
||||
@@ -26,6 +26,7 @@ from integrations.utils import (
|
||||
infer_repo_from_message,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from server.auth.constants import JIRA_HTTP_TIMEOUT
|
||||
from storage.jira_conversation import JiraConversation
|
||||
from storage.jira_integration_store import JiraIntegrationStore
|
||||
from storage.jira_user import JiraUser
|
||||
@@ -90,7 +91,9 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
|
||||
try:
|
||||
url = f'{JIRA_CLOUD_API_URL}/{self.jira_workspace.jira_cloud_id}/rest/api/2/issue/{self.payload.issue_key}'
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
auth=(
|
||||
|
||||
@@ -30,7 +30,7 @@ from integrations.utils import (
|
||||
markdown_to_jira_markup,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.constants import JIRA_DC_ENABLE_OAUTH
|
||||
from server.auth.constants import JIRA_DC_ENABLE_OAUTH, JIRA_DC_HTTP_TIMEOUT
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.jira_dc_integration_store import JiraDcIntegrationStore
|
||||
@@ -635,7 +635,9 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
"""Return the service account's Jira (name, key) from /myself."""
|
||||
url = f'{base_api_url}/rest/api/2/myself'
|
||||
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
@@ -647,7 +649,9 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
"""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(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code == 401:
|
||||
logger.error(
|
||||
@@ -712,7 +716,9 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
'maxResults': max_comments,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
raw_comments = response.json().get('comments', [])
|
||||
@@ -765,7 +771,9 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
|
||||
# Convert standard Markdown to Jira Wiki Markup for proper rendering
|
||||
data = {'body': markdown_to_jira_markup(message)}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -791,7 +799,9 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
url = f'{base_api_url}/rest/internal/2/reactions'
|
||||
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
|
||||
data = {'commentId': comment_id, 'emojiId': emoji_id}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -858,7 +868,9 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
'configuration': {'SECRET': secret, 'EXCLUDE_BODY': 'false'},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
# Idempotency: reuse any existing webhook already pointing at our URL.
|
||||
listing = await client.get(collection_url, headers=headers)
|
||||
listing.raise_for_status()
|
||||
@@ -911,7 +923,9 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
collection_url = f'{base}/rest/jira-webhook/1.0/webhooks'
|
||||
headers = {'Authorization': f'Bearer {admin_api_key}'}
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
listing = await client.get(collection_url, headers=headers)
|
||||
listing.raise_for_status()
|
||||
existing = next(
|
||||
|
||||
@@ -10,6 +10,7 @@ from server.auth.constants import (
|
||||
JIRA_DC_BASE_URL,
|
||||
JIRA_DC_CLIENT_ID,
|
||||
JIRA_DC_CLIENT_SECRET,
|
||||
JIRA_DC_HTTP_TIMEOUT,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.jira_dc_integration_store import JiraDcIntegrationStore
|
||||
@@ -81,7 +82,7 @@ async def get_user_jira_dc_token(
|
||||
refresh_token = token_manager.decrypt_text(enc_refresh)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=30.0
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(
|
||||
JIRA_DC_TOKEN_URL,
|
||||
|
||||
@@ -14,6 +14,7 @@ from integrations.jira_dc.jira_dc_service_account import (
|
||||
)
|
||||
from integrations.utils import get_summary_instruction, markdown_to_jira_markup
|
||||
from pydantic import Field
|
||||
from server.auth.constants import JIRA_DC_HTTP_TIMEOUT
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.jira_dc_integration_store import JiraDcIntegrationStore
|
||||
|
||||
@@ -183,7 +184,7 @@ class JiraDcV1CallbackProcessor(EventCallbackProcessor):
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
timeout=JIRA_DC_HTTP_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -211,7 +212,9 @@ class JiraDcV1CallbackProcessor(EventCallbackProcessor):
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_detail = f'Request timeout after 30 seconds to {url}'
|
||||
error_detail = (
|
||||
f'Request timeout after {JIRA_DC_HTTP_TIMEOUT:g} seconds to {url}'
|
||||
)
|
||||
_logger.exception(
|
||||
'[Jira DC] Timeout error: %s. Request payload: %s',
|
||||
error_detail,
|
||||
@@ -253,7 +256,9 @@ class JiraDcV1CallbackProcessor(EventCallbackProcessor):
|
||||
|
||||
headers = {'Authorization': f'Bearer {service_account.api_key}'}
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=JIRA_DC_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(
|
||||
comment_url,
|
||||
headers=headers,
|
||||
|
||||
@@ -18,7 +18,7 @@ from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.utils import CONVERSATION_URL
|
||||
from jinja2 import Environment
|
||||
from server.auth.constants import JIRA_DC_ENABLE_OAUTH
|
||||
from server.auth.constants import JIRA_DC_ENABLE_OAUTH, JIRA_DC_HTTP_TIMEOUT
|
||||
from storage.jira_dc_conversation import JiraDcConversation
|
||||
from storage.jira_dc_integration_store import JiraDcIntegrationStore
|
||||
from storage.jira_dc_user import JiraDcUser
|
||||
@@ -321,7 +321,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
timeout=JIRA_DC_HTTP_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
|
||||
@@ -39,6 +39,8 @@ ENABLE_LINEAR = os.environ.get('ENABLE_LINEAR', 'false') == 'true'
|
||||
ENABLE_AUTOMATIONS = os.environ.get('ENABLE_AUTOMATIONS', 'true') == 'true'
|
||||
JIRA_CLIENT_ID = os.getenv('JIRA_CLIENT_ID', '').strip()
|
||||
JIRA_CLIENT_SECRET = os.getenv('JIRA_CLIENT_SECRET', '').strip()
|
||||
# Timeout (s) for server-side calls to Jira Cloud; configurable, mirrors Jira DC.
|
||||
JIRA_HTTP_TIMEOUT = float(os.getenv('JIRA_HTTP_TIMEOUT', '30'))
|
||||
LINEAR_CLIENT_ID = os.getenv('LINEAR_CLIENT_ID', '').strip()
|
||||
LINEAR_CLIENT_SECRET = os.getenv('LINEAR_CLIENT_SECRET', '').strip()
|
||||
JIRA_DC_CLIENT_ID = os.getenv('JIRA_DC_CLIENT_ID', '').strip()
|
||||
@@ -47,6 +49,8 @@ JIRA_DC_BASE_URL = os.getenv('JIRA_DC_BASE_URL', '').strip()
|
||||
JIRA_DC_ENABLE_OAUTH = os.getenv('JIRA_DC_ENABLE_OAUTH', '1') in ('1', 'true')
|
||||
JIRA_DC_SERVICE_ACCOUNT_EMAIL = os.getenv('JIRA_DC_SERVICE_ACCOUNT_EMAIL', '').strip()
|
||||
JIRA_DC_SERVICE_ACCOUNT_PAT = os.getenv('JIRA_DC_SERVICE_ACCOUNT_PAT', '').strip()
|
||||
# Timeout (s) for server-side calls to Jira DC; configurable for slow on-prem.
|
||||
JIRA_DC_HTTP_TIMEOUT = float(os.getenv('JIRA_DC_HTTP_TIMEOUT', '30'))
|
||||
AUTH_URL = os.getenv('AUTH_URL', '').rstrip('/')
|
||||
ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
|
||||
'1',
|
||||
|
||||
@@ -280,6 +280,26 @@ class TestSendMessage:
|
||||
assert result == {'id': 'comment_id'}
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_uses_configured_timeout(self, jira_manager):
|
||||
"""Server-side Jira Cloud calls use the configured timeout, not httpx's 5s default."""
|
||||
from server.auth.constants import JIRA_HTTP_TIMEOUT
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {'id': 'comment_id'}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
await jira_manager.send_message(
|
||||
'Test message', 'PROJ-123', 'cloud-123', 'service@test.com', 'api_key'
|
||||
)
|
||||
|
||||
assert mock_client.call_args.kwargs['timeout'] == JIRA_HTTP_TIMEOUT
|
||||
|
||||
|
||||
class TestSendErrorFromPayload:
|
||||
"""Test error comment sending from payload."""
|
||||
|
||||
@@ -1819,3 +1819,35 @@ class TestAddAcknowledgementReaction:
|
||||
await jira_dc_manager._add_acknowledgement_reaction(
|
||||
sample_job_context, sample_jira_dc_workspace
|
||||
)
|
||||
|
||||
|
||||
class TestJiraDcHttpTimeout:
|
||||
"""Server-side Jira DC calls use the configurable timeout, not httpx's 5s default."""
|
||||
|
||||
def test_default_timeout_is_30s(self):
|
||||
from server.auth.constants import JIRA_DC_HTTP_TIMEOUT
|
||||
|
||||
assert JIRA_DC_HTTP_TIMEOUT == 30.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_account_call_uses_configured_timeout(self, jira_dc_manager):
|
||||
from server.auth.constants import JIRA_DC_HTTP_TIMEOUT
|
||||
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.json = MagicMock(return_value={'name': 'openhands', 'key': 'JIRAUSER1'})
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=client)
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
'integrations.jira_dc.jira_dc_manager.httpx.AsyncClient', return_value=cm
|
||||
) as mock_client:
|
||||
name, key = await jira_dc_manager._fetch_service_account_identity(
|
||||
'https://jira.example.com', 'svc-pat'
|
||||
)
|
||||
|
||||
assert (name, key) == ('openhands', 'JIRAUSER1')
|
||||
assert mock_client.call_args.kwargs['timeout'] == JIRA_DC_HTTP_TIMEOUT
|
||||
|
||||
Reference in New Issue
Block a user