Skip to content
28 changes: 24 additions & 4 deletions health_check/contrib/atlassian.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import httpx

from health_check import HealthCheck, __version__
from health_check.exceptions import ServiceUnavailable, ServiceWarning
from health_check.exceptions import ServiceUnavailable, StatusPageWarning

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -39,8 +39,11 @@ class AtlassianStatusPage(HealthCheck):
timeout: datetime.timedelta = NotImplemented

async def run(self):
if msg := "\n".join([i async for i in self._fetch_incidents()]):
raise ServiceWarning(msg)
if incidents := [i async for i in self._fetch_incidents()]:
raise StatusPageWarning(
"\n".join(msg for msg, _ in incidents),
timestamp=max(filter(None, (ts for _, ts in incidents)), default=None),
)
logger.debug("No recent incidents found")

async def _fetch_incidents(self):
Expand Down Expand Up @@ -73,7 +76,24 @@ async def _fetch_incidents(self):
raise ServiceUnavailable("Failed to parse JSON response") from e

for incident in data["incidents"]:
yield f"{incident['name']}: {incident['shortlink']}"
yield (
f"{incident['name']}: {incident['shortlink']}",
Comment thread
codingjoe marked this conversation as resolved.
Outdated
self._parse_incident_timestamp(incident),
)

def _parse_incident_timestamp(self, incident):
"""Extract and parse the most relevant timestamp from an incident dict."""
for field in ("started_at", "created_at", "updated_at"):
Comment thread
codingjoe marked this conversation as resolved.
Outdated
if ts_str := incident.get(field):
try:
return datetime.datetime.fromisoformat(
ts_str.replace("Z", "+00:00")
)
except ValueError:
logger.warning(
"Failed to parse incident timestamp %r", ts_str, exc_info=True
)
return None


@dataclasses.dataclass
Expand Down
31 changes: 17 additions & 14 deletions health_check/contrib/rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import httpx

from health_check import HealthCheck, __version__
from health_check.exceptions import ServiceUnavailable, ServiceWarning
from health_check.exceptions import ServiceUnavailable, StatusPageWarning

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -89,28 +89,31 @@ async def run(self):
logger.debug("No entries found in feed")
return

if incidents := [
entry for entry in feed.entries if self._is_recent_incident(entry)
]:
raise ServiceWarning(
if incidents := list(self._recent_incidents(feed.entries)):
raise StatusPageWarning(
"\n".join(
f"{getattr(entry, 'title', 'Unknown Incident') or 'Unknown Incident'}:"
f" {getattr(entry, 'link', self.feed_url) or self.feed_url}"
for entry in incidents
)
for entry, _ in incidents
),
timestamp=max(
filter(None, (date for _, date in incidents)), default=None
),
)

logger.debug("No recent incidents found in feed")

def _is_recent_incident(self, entry):
"""Check if entry is a recent incident."""
published_at = self._extract_date(entry)
if not published_at:
return True
def _recent_incidents(self, entries):
"""Yield recent (entry, timestamp) pairs from feed entries."""
for entry in entries:
date = self._extract_date(entry)
if date is None or self._is_date_recent(date):
yield entry, date

def _is_date_recent(self, date):
"""Check if a timestamp falls within the configured max_age window."""
now = datetime.datetime.now(tz=datetime.timezone.utc)
cutoff = now - self.max_age
return now >= published_at > cutoff
return now >= date > now - self.max_age

def _extract_date(self, entry):
# feedparser normalizes both RSS and Atom dates to struct_time
Expand Down
10 changes: 9 additions & 1 deletion health_check/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from django.utils import timezone


class HealthCheckException(Exception):
message_type: str = "Unknown Error"

def __init__(self, message):
def __init__(self, message, *, timestamp=None):
Comment thread
codingjoe marked this conversation as resolved.
self.message = message
self.timestamp = timestamp or timezone.now()
Comment thread
codingjoe marked this conversation as resolved.
Outdated

def __str__(self):
return f"{self.message_type}: {self.message}"
Expand All @@ -20,3 +24,7 @@ class ServiceUnavailable(HealthCheckException):

class ServiceReturnedUnexpectedResult(HealthCheckException):
message_type = "Unexpected Result"


class StatusPageWarning(ServiceWarning):
"""Warning from an external status page, carrying the source incident timestamp."""
3 changes: 1 addition & 2 deletions health_check/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

from django.db import transaction
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from django.utils.cache import patch_vary_headers
from django.utils.decorators import method_decorator
from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed
Expand Down Expand Up @@ -256,7 +255,7 @@ def _render_feed(self, feed_class):

for result in self.results:
published_at = (
timezone.now()
result.error.timestamp
if result.error
else datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
)
Expand Down
87 changes: 86 additions & 1 deletion tests/contrib/test_atlassian.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
from unittest import mock

import pytest
Expand All @@ -14,7 +15,11 @@
Sentry,
Vercel,
)
from health_check.exceptions import ServiceUnavailable, ServiceWarning
from health_check.exceptions import (
ServiceUnavailable,
ServiceWarning,
StatusPageWarning,
)


class TestFlyIo:
Expand Down Expand Up @@ -112,6 +117,86 @@ async def test_check_status__multiple_incidents(self):
assert "Database connectivity issues" in str(result.error)
assert "Network degradation" in str(result.error)

@pytest.mark.asyncio
async def test_check_status__incident_carries_source_timestamp(self):
"""StatusPageWarning carries the most recent incident created_at as its timestamp."""
api_response = {
"page": {"id": "test"},
"incidents": [
{
"name": "Older incident",
"shortlink": "https://stspg.io/older",
"created_at": "2024-01-01T00:00:00.000Z",
},
{
"name": "Newer incident",
"shortlink": "https://stspg.io/newer",
"created_at": "2024-01-01T06:00:00.000Z",
},
],
}

with mock.patch(
"health_check.contrib.atlassian.httpx.AsyncClient"
) as mock_client:
mock_response = mock.MagicMock()
mock_response.json.return_value = api_response
mock_response.raise_for_status = mock.MagicMock()

mock_context = mock.AsyncMock()
mock_context.__aenter__.return_value.get = mock.AsyncMock(
return_value=mock_response
)
mock_client.return_value = mock_context

check = FlyIo()
result = await check.get_result()
assert result.error is not None
assert isinstance(result.error, StatusPageWarning)
expected_ts = datetime.datetime(
2024, 1, 1, 6, 0, 0, tzinfo=datetime.timezone.utc
)
assert result.error.timestamp == expected_ts, (
"StatusPageWarning should carry the most recent incident timestamp"
)

@pytest.mark.asyncio
async def test_check_status__incident_with_invalid_timestamp(self):
"""Incidents with unparseable timestamps are treated as having no timestamp."""
api_response = {
"page": {"id": "test"},
"incidents": [
{
"name": "Incident with bad date",
"shortlink": "https://stspg.io/xyz",
"created_at": "not-a-date",
}
],
}

with mock.patch(
"health_check.contrib.atlassian.httpx.AsyncClient"
) as mock_client:
mock_response = mock.MagicMock()
mock_response.json.return_value = api_response
mock_response.raise_for_status = mock.MagicMock()

mock_context = mock.AsyncMock()
mock_context.__aenter__.return_value.get = mock.AsyncMock(
return_value=mock_response
)
mock_client.return_value = mock_context

before = datetime.datetime.now(tz=datetime.timezone.utc)
check = FlyIo()
result = await check.get_result()
after = datetime.datetime.now(tz=datetime.timezone.utc)
assert result.error is not None
assert isinstance(result.error, StatusPageWarning)
assert before <= result.error.timestamp <= after, (
"Unparseable incident timestamp should fall back to current time"
)

@pytest.mark.asyncio
async def test_check_status__http_error(self):
"""Raise ServiceUnavailable on HTTP error."""
Expand Down
55 changes: 54 additions & 1 deletion tests/contrib/test_rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
GoogleCloud,
Heroku,
)
from health_check.exceptions import ServiceUnavailable, ServiceWarning # noqa: E402
from health_check.exceptions import ( # noqa: E402
ServiceUnavailable,
ServiceWarning,
StatusPageWarning,
)


class TestAWS:
Expand Down Expand Up @@ -104,6 +108,55 @@ async def test_check_status__multiple_incidents(self):
assert isinstance(result.error, ServiceWarning)
assert "\n" in str(result.error)

@pytest.mark.asyncio
async def test_check_status__incident_carries_source_timestamp(self):
"""StatusPageWarning carries the most recent incident date as its timestamp."""
rss_content = b"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>Older incident</title>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
</item>
<item>
<title>Newer incident</title>
<pubDate>Mon, 01 Jan 2024 06:00:00 GMT</pubDate>
</item>
</channel>
</rss>"""

with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client:
mock_response = mock.MagicMock()
mock_response.text = rss_content.decode("utf-8")
mock_response.raise_for_status = mock.MagicMock()

mock_context = mock.AsyncMock()
mock_context.__aenter__.return_value.get = mock.AsyncMock(
return_value=mock_response
)
mock_client.return_value = mock_context

mock_now = datetime.datetime(
2024, 1, 1, 8, 0, 0, tzinfo=datetime.timezone.utc
)
with mock.patch(
"health_check.contrib.rss.datetime", wraps=datetime
) as mock_datetime:
mock_datetime.datetime = mock.Mock(wraps=datetime.datetime)
mock_datetime.datetime.now = mock.Mock(return_value=mock_now)
mock_datetime.timezone = datetime.timezone

check = AWS(region="us-east-1", service="ec2")
result = await check.get_result()
assert result.error is not None
assert isinstance(result.error, StatusPageWarning)
expected_ts = datetime.datetime(
2024, 1, 1, 6, 0, 0, tzinfo=datetime.timezone.utc
)
assert result.error.timestamp == expected_ts, (
"StatusPageWarning should carry the most recent incident date as its timestamp"
)

@pytest.mark.asyncio
async def test_check_status__no_recent_incidents(self):
"""Old incidents are filtered out."""
Expand Down
53 changes: 53 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""Unit tests for health_check.exceptions module."""

import datetime

import pytest
from django.utils import timezone

from health_check.exceptions import (
HealthCheckException,
ServiceReturnedUnexpectedResult,
ServiceUnavailable,
ServiceWarning,
StatusPageWarning,
)


Expand All @@ -16,6 +20,19 @@ def test_init__store_message(self):
exc = HealthCheckException("test message")
assert exc.message == "test message"

def test_init__timestamp_defaults_to_now(self):
"""Default timestamp to current time when not provided."""
before = timezone.now()
exc = HealthCheckException("test message")
after = timezone.now()
assert before <= exc.timestamp <= after

def test_init__store_timestamp(self):
"""Store explicit timestamp passed to constructor."""
ts = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
exc = HealthCheckException("test message", timestamp=ts)
assert exc.timestamp == ts

def test_str__format_with_type(self):
"""Format string with message type and message."""
exc = HealthCheckException("foo")
Expand Down Expand Up @@ -100,3 +117,39 @@ def test_can_raise_and_catch_as_specific_type(self):
"""Can be caught as ServiceReturnedUnexpectedResult specifically."""
with pytest.raises(ServiceReturnedUnexpectedResult):
raise ServiceReturnedUnexpectedResult("unexpected result message")


class TestStatusPageWarning:
def test_init__store_message(self):
"""Store message passed to constructor."""
exc = StatusPageWarning("incident detected")
assert exc.message == "incident detected"

def test_init__timestamp_defaults_to_now(self):
"""Default timestamp to current time when not provided."""
before = timezone.now()
exc = StatusPageWarning("incident detected")
after = timezone.now()
assert before <= exc.timestamp <= after

def test_init__store_timestamp(self):
"""Store timestamp passed to constructor."""
ts = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
exc = StatusPageWarning("incident detected", timestamp=ts)
assert exc.timestamp == ts

def test_str__format_with_warning_type(self):
"""Format string with 'warning' message type."""
exc = StatusPageWarning("incident detected")
assert str(exc) == "Warning: incident detected"

def test_inherits_from_service_warning(self):
"""Inherit from ServiceWarning."""
exc = StatusPageWarning("incident")
assert isinstance(exc, ServiceWarning)
assert isinstance(exc, HealthCheckException)

def test_can_raise_and_catch_as_service_warning(self):
"""Can be caught as ServiceWarning."""
with pytest.raises(ServiceWarning):
raise StatusPageWarning("incident")
Loading