Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

484 lines
19 KiB
Python

"""incident.io REST API client for investigation context and write-back."""
from __future__ import annotations
import email.utils
import logging
import random
import re
import threading
import time
from datetime import UTC, datetime
from typing import Any
import httpx
from integrations.config_models import IncidentIoIntegrationConfig
from integrations.probes import ProbeResult
logger = logging.getLogger(__name__)
IncidentIoConfig = IncidentIoIntegrationConfig
_DEFAULT_TIMEOUT = 30
_MAX_RETRIES = 3
_APPEND_SUMMARY_VERIFY_ATTEMPTS = 5
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
_IDEMPOTENT_METHODS = {"GET", "HEAD", "OPTIONS"}
_SECRET_RE = re.compile(
r"(?i)(bearer\s+[A-Za-z0-9._~+/=-]{6,}"
r"|authorization\s*[:=]\s*\S+"
r"|incident[_-]?io[_-]?(api[_-]?)?key\s*[:=]\s*\S+"
r"|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"
)
# Module-level write locks keyed by incident ID so concurrent *thread* invocations
# within the same process that target the same incident are serialised. Each
# distinct active incident adds one Lock (~50 bytes); in practice the set is
# bounded by the number of incidents in flight during an investigation session.
_INCIDENT_WRITE_LOCKS: dict[str, threading.Lock] = {}
_INCIDENT_WRITE_LOCKS_META = threading.Lock()
def _get_incident_write_lock(incident_id: str) -> threading.Lock:
with _INCIDENT_WRITE_LOCKS_META:
if incident_id not in _INCIDENT_WRITE_LOCKS:
_INCIDENT_WRITE_LOCKS[incident_id] = threading.Lock()
return _INCIDENT_WRITE_LOCKS[incident_id]
def _safe_int(value: int | None, default: int, maximum: int) -> int:
if value is None:
return default
return max(1, min(int(value), maximum))
def _parse_retry_after(value: str | None) -> float | None:
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
try:
parsed = email.utils.parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
if parsed is None:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return max(0.0, (parsed - datetime.now(UTC)).total_seconds())
def _retry_after_from_response(response: httpx.Response) -> float | None:
header_retry_after = _parse_retry_after(response.headers.get("Retry-After"))
if header_retry_after is not None:
return header_retry_after
try:
payload = response.json()
except ValueError:
return None
if not isinstance(payload, dict):
return None
rate_limit = payload.get("rate_limit")
if not isinstance(rate_limit, dict):
return None
return _parse_retry_after(str(rate_limit.get("retry_after") or ""))
class IncidentIoClient:
"""Synchronous client for the incident.io v2 API."""
def __init__(self, config: IncidentIoConfig) -> None:
self.config = config
self._client: httpx.Client | None = None
self._client_lock = threading.RLock()
@property
def is_configured(self) -> bool:
return bool(self.config.api_key)
def _get_client(self) -> httpx.Client:
with self._client_lock:
if self._client is None:
self._client = httpx.Client(
base_url=self.config.base_url,
headers=self.config.headers,
timeout=_DEFAULT_TIMEOUT,
)
return self._client
def close(self) -> None:
with self._client_lock:
if self._client is not None:
self._client.close()
self._client = None
def __enter__(self) -> IncidentIoClient:
return self
def __exit__(self, *_: object) -> None:
self.close()
def _redact(self, value: object) -> str:
text = str(value)
if self.config.api_key:
text = text.replace(self.config.api_key, "[REDACTED]")
return _SECRET_RE.sub("[REDACTED]", text)
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
method_upper = method.upper()
for attempt in range(_MAX_RETRIES + 1):
try:
response = self._get_client().request(method_upper, path, **kwargs)
if response.status_code not in _RETRYABLE_STATUS_CODES:
return response
response.raise_for_status()
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code
should_retry = status_code == 429 or method_upper in _IDEMPOTENT_METHODS
if status_code not in _RETRYABLE_STATUS_CODES or not should_retry:
raise
if attempt >= _MAX_RETRIES:
raise
retry_after = _retry_after_from_response(exc.response)
sleep_for = retry_after if retry_after is not None else (2**attempt)
sleep_for += random.random() * 0.1
logger.warning(
"[incident_io] %s %s returned HTTP %s; retrying in %.2fs",
method_upper,
path,
status_code,
sleep_for,
)
time.sleep(sleep_for)
except httpx.RequestError as exc:
if method_upper not in _IDEMPOTENT_METHODS or attempt >= _MAX_RETRIES:
raise
sleep_for = (2**attempt) + (random.random() * 0.1)
logger.warning(
"[incident_io] %s %s request failed; retrying in %.2fs: %s",
method_upper,
path,
sleep_for,
self._redact(exc),
)
time.sleep(sleep_for)
raise AssertionError("incident.io _request loop exited without return or raise")
def probe_access(self) -> ProbeResult:
"""Validate credentials with a minimal incident list request."""
if not self.is_configured:
return ProbeResult.missing("Missing API key.")
try:
response = self._request("GET", "/v2/incidents", params={"page_size": 1})
response.raise_for_status()
except Exception as exc:
return ProbeResult.failed(
f"Connection failed: {self._redact(exc)}",
base_url=self.config.base_url,
)
return ProbeResult.passed(
"Connected to incident.io; API key accepted.",
base_url=self.config.base_url,
)
def list_incidents(
self,
*,
status_category: str = "live",
page_size: int | None = 20,
after: str | None = None,
) -> dict[str, Any]:
"""List incidents, filtered by incident.io status category when supplied."""
params: dict[str, Any] = {"page_size": _safe_int(page_size, 20, 500)}
if status_category:
params["status_category[one_of]"] = status_category
if after:
params["after"] = after
try:
response = self._request("GET", "/v2/incidents", params=params)
response.raise_for_status()
data = response.json()
incidents = [_format_incident(item) for item in data.get("incidents", [])]
result: dict[str, Any] = {
"success": True,
"incidents": incidents,
"total": len(incidents),
}
if "pagination_meta" in data:
result["pagination_meta"] = data["pagination_meta"]
return result
except httpx.HTTPStatusError as exc:
err_text = self._redact(exc.response.text[:300])
logger.warning(
"[incident_io] List incidents HTTP failure status=%s error=%r",
exc.response.status_code,
err_text,
)
return {"success": False, "error": f"HTTP {exc.response.status_code}: {err_text}"}
except Exception as exc:
err_text = self._redact(exc)
logger.warning("[incident_io] List incidents error: %s", err_text)
return {"success": False, "error": err_text}
def get_incident(self, incident_id: str) -> dict[str, Any]:
"""Fetch full details for a specific incident."""
try:
response = self._request("GET", f"/v2/incidents/{incident_id}")
response.raise_for_status()
incident = _format_incident(response.json().get("incident", {}), full=True)
return {"success": True, "incident": incident}
except httpx.HTTPStatusError as exc:
err_text = self._redact(exc.response.text[:300])
logger.warning(
"[incident_io] Get incident HTTP failure status=%s id=%r error=%r",
exc.response.status_code,
incident_id,
err_text,
)
return {"success": False, "error": f"HTTP {exc.response.status_code}: {err_text}"}
except Exception as exc:
err_text = self._redact(exc)
logger.warning("[incident_io] Get incident error: %s", err_text)
return {"success": False, "error": err_text}
def list_incident_updates(
self,
incident_id: str,
*,
page_size: int | None = 25,
after: str | None = None,
) -> dict[str, Any]:
"""List incident updates, which provide incident timeline/status context."""
params: dict[str, Any] = {
"incident_id": incident_id,
"page_size": _safe_int(page_size, 25, 250),
}
if after:
params["after"] = after
try:
response = self._request("GET", "/v2/incident_updates", params=params)
response.raise_for_status()
data = response.json()
updates = [_format_update(item) for item in data.get("incident_updates", [])]
result: dict[str, Any] = {
"success": True,
"incident_updates": updates,
"total": len(updates),
}
if "pagination_meta" in data:
result["pagination_meta"] = data["pagination_meta"]
return result
except httpx.HTTPStatusError as exc:
err_text = self._redact(exc.response.text[:300])
logger.warning(
"[incident_io] List updates HTTP failure status=%s id=%r error=%r",
exc.response.status_code,
incident_id,
err_text,
)
return {"success": False, "error": f"HTTP {exc.response.status_code}: {err_text}"}
except Exception as exc:
err_text = self._redact(exc)
logger.warning("[incident_io] List updates error: %s", err_text)
return {"success": False, "error": err_text}
def get_incident_context(
self,
incident_id: str,
*,
update_limit: int | None = 25,
) -> dict[str, Any]:
"""Fetch incident metadata and update timeline context in one call."""
incident_result = self.get_incident(incident_id)
if not incident_result.get("success"):
return incident_result
updates_result = self.list_incident_updates(incident_id, page_size=update_limit)
if not updates_result.get("success"):
return {
"success": False,
"incident": incident_result.get("incident", {}),
"error": updates_result.get("error", "unknown error"),
}
return {
"success": True,
"incident": incident_result.get("incident", {}),
"incident_updates": updates_result.get("incident_updates", []),
"total_updates": updates_result.get("total", 0),
}
def append_summary_update(
self,
incident_id: str,
*,
title: str,
body: str = "",
notify_incident_channel: bool = False,
) -> dict[str, Any]:
"""Append OpenSRE findings to an incident summary via the supported edit endpoint.
Uses read-post-verify with retries so concurrent writers (including separate OS
processes) typically converge: if another writer posts between our read and
write, verification misses our appended text and we merge again from the latest
summary before re-posting.
"""
timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
finding = f"\n\n---\n**OpenSRE finding: {title}** ({timestamp})"
if body:
finding = f"{finding}\n{body}"
last_error: str | None = None
for verify_attempt in range(_APPEND_SUMMARY_VERIFY_ATTEMPTS):
with _get_incident_write_lock(incident_id):
incident_result = self.get_incident(incident_id)
if not incident_result.get("success"):
return incident_result
incident = incident_result.get("incident", {})
current_summary = str(incident.get("summary") or "")
if finding in current_summary:
return {"success": True, "summary": current_summary.strip()}
updated_summary = (current_summary + finding).strip()
payload = {
"incident": {"summary": updated_summary},
"notify_incident_channel": notify_incident_channel,
}
try:
response = self._request(
"POST",
f"/v2/incidents/{incident_id}/actions/edit",
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
err_text = self._redact(exc.response.text[:300])
logger.warning(
"[incident_io] Summary append HTTP failure status=%s id=%r error=%r",
exc.response.status_code,
incident_id,
err_text,
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {err_text}",
}
except Exception as exc:
err_text = self._redact(exc)
logger.warning("[incident_io] Summary append error: %s", err_text)
return {"success": False, "error": err_text}
verify_result = self.get_incident(incident_id)
if not verify_result.get("success"):
last_error = str(verify_result.get("error", "verify fetch failed"))
logger.warning(
"[incident_io] Summary verify GET failed id=%r attempt=%s: %s",
incident_id,
verify_attempt + 1,
last_error,
)
else:
verified = str(verify_result.get("incident", {}).get("summary") or "")
if finding in verified:
return {"success": True, "summary": verified.strip()}
last_error = "summary verify mismatch after edit (concurrent writer?)"
if verify_attempt >= _APPEND_SUMMARY_VERIFY_ATTEMPTS - 1:
break
# Retry outside the per-incident lock so another process can finish its write.
sleep_for = (0.2 * (2**verify_attempt)) + random.random() * 0.05
logger.warning(
"[incident_io] Summary append verify retry id=%r attempt=%s in %.2fs: %s",
incident_id,
verify_attempt + 1,
sleep_for,
last_error,
)
time.sleep(sleep_for)
return {
"success": False,
"error": last_error or "summary append failed verification after retries",
}
def _format_incident(data: dict[str, Any], *, full: bool = False) -> dict[str, Any]:
status = data.get("incident_status") or {}
severity = data.get("severity") or {}
incident_type = data.get("incident_type") or {}
formatted: dict[str, Any] = {
"id": data.get("id", ""),
"reference": data.get("reference", ""),
"name": data.get("name", ""),
"permalink": data.get("permalink", ""),
"status": status.get("name", ""),
"status_category": status.get("category", ""),
"severity": severity.get("name", ""),
"severity_rank": severity.get("rank"),
"incident_type": incident_type.get("name", ""),
"summary": data.get("summary", ""),
"created_at": data.get("created_at", ""),
"updated_at": data.get("updated_at", ""),
}
if full:
formatted.update(
{
"custom_field_entries": data.get("custom_field_entries", []),
"incident_role_assignments": data.get("incident_role_assignments", []),
"incident_timestamp_values": data.get("incident_timestamp_values", []),
"duration_metrics": data.get("duration_metrics", []),
"external_issue_reference": data.get("external_issue_reference"),
"slack_channel_name": data.get("slack_channel_name", ""),
"visibility": data.get("visibility", ""),
}
)
return formatted
def _format_update(data: dict[str, Any]) -> dict[str, Any]:
status = data.get("new_incident_status") or {}
severity = data.get("new_severity") or {}
updater = data.get("updater") or {}
user = updater.get("user") or {}
api_key = updater.get("api_key") or {}
return {
"id": data.get("id", ""),
"incident_id": data.get("incident_id", ""),
"created_at": data.get("created_at", ""),
"message": data.get("message", ""),
"new_status": status.get("name", ""),
"new_status_category": status.get("category", ""),
"new_severity": severity.get("name", ""),
"updater": {
"user_name": user.get("name", ""),
"user_email": user.get("email", ""),
"api_key_name": api_key.get("name", ""),
},
}
def make_incident_io_client(
api_key: str | None,
region: str | None = None,
*,
base_url: str | None = "",
) -> IncidentIoClient | None:
"""Create an incident.io client if a usable API key is provided."""
token = (api_key or "").strip()
if not token:
return None
try:
# ``region`` is accepted for backward compatibility with early integration
# drafts, but incident.io documents a single public API host.
_ = region
config = IncidentIoConfig(api_key=token, base_url=base_url or "")
return IncidentIoClient(config)
except Exception as exc:
logger.warning("[incident_io] Failed to build client config: %s", exc)
return None