From a9db860ac751d45ff2aed3cbb73d16c3b3805a4c Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:45:29 +0800 Subject: [PATCH] feat: log weather provider usage --- README.md | 2 +- docs/design.md | 2 +- docs/requirements.md | 2 +- tests/test_cli.py | 7 +- tests/test_weather_context.py | 108 ++++++++++++++++++++++++---- weather_briefing/cli.py | 7 +- weather_briefing/weather_context.py | 91 ++++++++++++++++++++--- 7 files changed, 191 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2ad450c6..9001667a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ uv run --frozen weather-briefing run hourly `LLM_PROVIDER=deepseek` 使用 `DEEPSEEK_API_KEY`、`DEEPSEEK_MODEL` 和可选的 `DEEPSEEK_BASE_URL`;DeepSeek provider 已预置官方 Base URL。`LLM_PROVIDER=openai-compatible` 使用 `LLM_API_KEY`、`LLM_MODEL` 和 `LLM_BASE_URL`。两套配置互不回退。 -应用将带时间、级别和 logger 名称的运行日志写入标准错误;设置 `DEBUG=true` 可输出 RSS 获取、LLM 重试,以及从 RSS 清洗、权威预报转发和平台渲染到 Telegram 分片接受状态的非敏感诊断信息。该链路只记录来源、发布时间、字符数和分片状态,不记录标题、正文、URL、token、chat ID 或请求 endpoint。若仍需排查平台渲染或分片内容,可在不重启 daemon 的情况下临时记录完整渲染正文: +应用将带时间、级别和 logger 名称的运行日志写入标准错误;INFO 日志记录每个地点的天气 provider 顺序,以及每次天气 API 尝试、成功、失败、耗时、实际来源和安全的失败原因,因此发生自动降级时可从容器日志还原调用历史。设置 `DEBUG=true` 可输出 RSS 获取、LLM 重试,以及从 RSS 清洗、权威预报转发和平台渲染到 Telegram 分片接受状态的非敏感诊断信息。该链路不记录坐标、标题、正文、URL、token、chat ID 或请求 endpoint。若仍需排查平台渲染或分片内容,可在不重启 daemon 的情况下临时记录完整渲染正文: ```bash weather-briefing diagnostics rendered-text enable --for 15m diff --git a/docs/design.md b/docs/design.md index 458fa9d5..addb616a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -103,7 +103,7 @@ RSS 是可选补充源,其失败不影响任务成功率;天气 API 是主 小时 LLM 结果包含布尔字段 `should_publish`。模型比较当前及历史 API 快照,仅在降雨、显著天气变化、预警或灾害动态值得打扰时设为真;活动预警不允许与 false 同时出现。false 结果不投递消息,但当前快照、文章去重和预警状态仍持久化。 -CLI 在读取运行配置前以 INFO 幂等配置单个标准错误 handler,配置成功后再按 `DEBUG` 更新级别,避免配置错误绕过统一格式、daemon 每轮任务重复追加 handler 或向 root logger 重复传播。默认记录生命周期、文章数量、陈旧来源和失败信息;`DEBUG` 启用 RSS 获取及 LLM 重试诊断,并以非敏感元数据串联 RSS 清洗后的来源、发布时间、正文长度和权威预报标记,权威预报投递前后的来源、发布时间和正文长度,renderer 的可见与 payload 长度,以及 Telegram 分片总数、逐片长度和平台接受状态。这条链路不记录标题、正文、URL、token、chat ID 或请求 endpoint。业务层只给异常追加失败计数等上下文,完整堆栈由 CLI 入口或 APScheduler 单点记录。 +CLI 在读取运行配置前以 INFO 幂等配置单个标准错误 handler,配置成功后再按 `DEBUG` 更新级别,避免配置错误绕过统一格式、daemon 每轮任务重复追加 handler 或向 root logger 重复传播。默认记录生命周期、文章数量、陈旧来源和失败信息。天气 provider 在组合边界由日志装饰器包装:先记录每个地点的有效 provider 顺序,再为每次逻辑调用记录 provider、结果、耗时、实际 source ID 与观测时间;主来源失败时记录经过收敛的阶段、HTTP 状态或异常类型,随后才由 fallback 组合继续。可选空气质量调用失败单独记录,不把可用天气结果改为失败。`DEBUG` 启用 RSS 获取及 LLM 重试诊断,并以非敏感元数据串联 RSS 清洗后的来源、发布时间、正文长度和权威预报标记,权威预报投递前后的来源、发布时间和正文长度,renderer 的可见与 payload 长度,以及 Telegram 分片总数、逐片长度和平台接受状态。这条链路不记录坐标、标题、正文、URL、token、chat ID 或请求 endpoint。业务层只给异常追加失败计数等上下文,完整堆栈由 CLI 入口或 APScheduler 单点记录。 完整渲染文本诊断采用第二道运行时开关,避免仅凭长期 `DEBUG` 配置泄露正文。`diagnostics rendered-text enable --for `、`status` 和 `disable` 直接读写 `BRIEFING_STATE_PATH` 中带 UTC 过期时间的 SQLite 状态;每次投递在记录前重新读取,因此另一个 CLI 进程的修改无需重启 daemon 即可生效。开关最长 24 小时,首次观察到过期状态时删除记录并发出警告。`DeliveryProvider` 记录 renderer 的完整输出,Telegram publisher 另记实际发送的每个分片,以区分渲染与传输边界;两处都只在 DEBUG 与开关同时有效时输出,并且不记录 token、chat ID 或请求 endpoint。诊断状态后端是可选能力:初始化或状态检查失败只记录警告并视为关闭,不改变消息投递。 diff --git a/docs/requirements.md b/docs/requirements.md index c5fc545c..01fd2ede 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -48,7 +48,7 @@ 7. 配置项应经过类型、范围和必填校验;provider、source 和状态存储保持明确扩展边界,避免厂商逻辑进入核心编排。 8. DeepSeek 使用 `DEEPSEEK_*` 配置,通用 OpenAI-compatible provider 使用 `LLM_*` 配置;RSS 只从命名 JSON 文件读取。SQLite 没有原生日期时间类型,应用仅在持久化边界将时区感知时间转换为固定宽度 UTC 文本,使文本字典序等同绝对时间顺序。 9. 地理范围、空气质量分级与健康提示、正文清洗默认规则、provider 默认顺序及厂商指数代码等纯领域数据应存放在独立数据文件中,由实现代码加载并校验。 -10. DEBUG 日志应以非敏感元数据覆盖 RSS 清洗、权威预报转发、平台渲染和分片投递边界,使运维可以根据来源、发布时间、字符数、分片数和平台接受状态定位内容丢失阶段,而无需记录标题、正文、URL、投递凭据、接收方标识或私有 endpoint。完整渲染正文属于敏感诊断数据,默认不得记录;运行时可以通过 CLI 临时启用、查询或关闭该行为,无需重启 daemon。启用时长必须为正且不超过 24 小时,到期自动失效。只有 DEBUG 日志级别与临时开关同时有效时才输出正文,并覆盖投递 provider 生成的完整消息及平台分片。诊断状态初始化或读取失败不得阻断正常投递。 +10. INFO 日志必须记录每个地点的有效天气 provider 顺序,并为每次天气 API 逻辑调用记录 provider、成功或失败、耗时、成功时的实际来源与观测时间,以及失败时不包含请求内容的阶段、HTTP 状态或异常类型,使自动降级可追溯。可选空气质量失败也必须记录。日志不得包含坐标、凭据、私有 endpoint 或响应正文。DEBUG 日志应以非敏感元数据覆盖 RSS 清洗、权威预报转发、平台渲染和分片投递边界,使运维可以根据来源、发布时间、字符数、分片数和平台接受状态定位内容丢失阶段,而无需记录标题、正文、URL、投递凭据、接收方标识或私有 endpoint。完整渲染正文属于敏感诊断数据,默认不得记录;运行时可以通过 CLI 临时启用、查询或关闭该行为,无需重启 daemon。启用时长必须为正且不超过 24 小时,到期自动失效。只有 DEBUG 日志级别与临时开关同时有效时才输出正文,并覆盖投递 provider 生成的完整消息及平台分片。诊断状态初始化或读取失败不得阻断正常投递。 ## 投递假设 diff --git a/tests/test_cli.py b/tests/test_cli.py index cc544ab5..99aafdac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -805,7 +805,7 @@ async def test_single_provider_bypasses_fallback(self, async_client: httpx.Async provider = _weather_context_provider(settings, async_client, location) assert provider is not None - async def test_qweather_configured(self, async_client: httpx.AsyncClient) -> None: + async def test_qweather_configured(self, async_client: httpx.AsyncClient, caplog) -> None: key = b"fake-private-key-content" settings = _make_fake_settings( weather_providers=("qweather",), @@ -815,8 +815,11 @@ async def test_qweather_configured(self, async_client: httpx.AsyncClient) -> Non qweather_base_url="https://qweather.example.invalid", ) location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True) - provider = _weather_context_provider(settings, async_client, location) + with caplog.at_level("INFO", logger="weather_briefing"): + provider = _weather_context_provider(settings, async_client, location) assert provider is not None + assert "Weather provider order providers=qweather" in caplog.text + assert "location=test" not in caplog.text async def test_no_weather_provider_available(monkeypatch, async_client: httpx.AsyncClient) -> None: diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index b4c60910..839109c5 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -10,6 +10,7 @@ from weather_briefing.weather_context import ( AirQualitySupplementingWeatherProvider, FallbackWeatherContextProvider, + LoggedWeatherContextProvider, OpenMeteoProvider, QWeatherJWTAuthenticator, QWeatherProvider, @@ -267,6 +268,56 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh assert await provider.fetch(1, 2) == expected +async def test_weather_provider_logs_failed_fallback_and_successful_call(caplog) -> None: + expected = WeatherContextSnapshot( + source_id="weather:fallback", + source_name="Fallback weather", + source_url="https://example.invalid/weather", + observed_at=pendulum.datetime(2026, 7, 13, 8, tz="UTC"), + weather_forecast=("forecast",), + ) + + class FailingProvider: + async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: + raise WeatherContextError("QWeather weather forecast failed: HTTP 401") + + class SuccessfulProvider: + async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: + return expected + + provider = FallbackWeatherContextProvider( + LoggedWeatherContextProvider("qweather", FailingProvider()), + LoggedWeatherContextProvider("open-meteo", SuccessfulProvider()), + ) + + with caplog.at_level("INFO", logger="weather_briefing.weather_context"): + assert await provider.fetch(1, 2) == expected + + assert "Weather API call started provider=qweather" in caplog.text + assert "Weather API call failed provider=qweather" in caplog.text + assert "reason=QWeather weather forecast failed: HTTP 401" in caplog.text + assert "Weather API call succeeded provider=open-meteo" in caplog.text + assert "source_id=weather:fallback" in caplog.text + + +async def test_weather_provider_logs_unexpected_error_type_without_message(caplog) -> None: + class UnexpectedFailureProvider: + async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: + raise RuntimeError("sensitive upstream detail") + + provider = LoggedWeatherContextProvider("test-provider", UnexpectedFailureProvider()) + + with ( + caplog.at_level("WARNING", logger="weather_briefing.weather_context"), + pytest.raises(RuntimeError, match="sensitive upstream detail"), + ): + await provider.fetch(1, 2) + + assert "Weather API call failed provider=test-provider" in caplog.text + assert "reason=RuntimeError" in caplog.text + assert "sensitive upstream detail" not in caplog.text + + async def test_missing_weather_air_quality_requires_optional_aqicn_configuration() -> None: snapshot = WeatherContextSnapshot( source_id="weather:test", @@ -399,7 +450,7 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(f"Unexpected request: {request.url}") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - with pytest.raises(WeatherContextError, match="non-success weather status"): + with pytest.raises(WeatherContextError, match="non-success weather status code=400"): await QWeatherProvider( client, authenticator=StaticAuthenticator(), @@ -407,6 +458,31 @@ def handler(request: httpx.Request) -> httpx.Response: ).fetch(1, 2) +async def test_qweather_does_not_log_untrusted_api_status(caplog) -> None: + untrusted_status = "400\nforged-log-entry" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": untrusted_status}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = LoggedWeatherContextProvider( + "qweather", + QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ), + ) + with ( + caplog.at_level("WARNING", logger="weather_briefing.weather_context"), + pytest.raises(WeatherContextError, match="non-success weather status code=invalid"), + ): + await provider.fetch(1, 2) + + assert untrusted_status not in caplog.text + assert "reason=QWeather returned a non-success weather status code=invalid" in caplog.text + + async def test_qweather_rejects_empty_daily_forecast() -> None: def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/v7/weather/3d": @@ -425,7 +501,7 @@ def handler(request: httpx.Request) -> httpx.Response: ).fetch(1, 2) -async def test_qweather_air_quality_failure_is_silent() -> None: +async def test_qweather_air_quality_failure_is_logged_without_failing_weather(caplog) -> None: def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/v7/weather/3d": return _qweather_weather_response(fx_link="https://www.qweather.com/") @@ -434,13 +510,15 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(500) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - snapshot = await QWeatherProvider( - client, - authenticator=StaticAuthenticator(), - base_url="https://api.example.invalid", - ).fetch(1, 2) + with caplog.at_level("WARNING", logger="weather_briefing.weather_context"): + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) assert snapshot.air_quality is None + assert "provider=qweather operation=air-quality reason=HTTP 500" in caplog.text async def test_open_meteo_rejects_empty_forecast() -> None: @@ -460,7 +538,7 @@ async def test_open_meteo_rejects_empty_forecast() -> None: await OpenMeteoProvider(client).fetch(1, 2) -async def test_open_meteo_air_quality_failure_is_silent() -> None: +async def test_open_meteo_air_quality_failure_is_logged_without_failing_weather(caplog) -> None: def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/v1/forecast": return httpx.Response( @@ -487,9 +565,11 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(500) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - snapshot = await OpenMeteoProvider(client).fetch(1, 2) + with caplog.at_level("WARNING", logger="weather_briefing.weather_context"): + snapshot = await OpenMeteoProvider(client).fetch(1, 2) assert snapshot.air_quality is None + assert "provider=open-meteo operation=air-quality reason=HTTP 500" in caplog.text async def test_fallback_weather_provider_requires_at_least_one_provider() -> None: @@ -580,7 +660,7 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_qweather_rejects_http_error() -> None: async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client: - with pytest.raises(WeatherContextError, match="request or response validation failed"): + with pytest.raises(WeatherContextError, match="weather forecast failed: HTTP 500"): await QWeatherProvider( client, authenticator=StaticAuthenticator(), @@ -594,7 +674,7 @@ def authorization_header(self) -> str: raise jwt.PyJWTError("test jwt failure") async with httpx.AsyncClient() as client: - with pytest.raises(WeatherContextError, match="request or response validation failed"): + with pytest.raises(WeatherContextError, match="authentication failed: PyJWTError"): await QWeatherProvider( client, authenticator=FailingAuthenticator(), @@ -638,7 +718,7 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_open_meteo_rejects_http_error() -> None: async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client: - with pytest.raises(WeatherContextError, match="request or response validation failed"): + with pytest.raises(WeatherContextError, match="weather forecast failed: HTTP 500"): await OpenMeteoProvider(client).fetch(1, 2) @@ -797,7 +877,7 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(f"Unexpected request: {request.url}") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - with pytest.raises(WeatherContextError, match="request or response validation failed"): + with pytest.raises(WeatherContextError, match="lifestyle indices failed: ValueError"): await QWeatherProvider( client, authenticator=StaticAuthenticator(), @@ -822,7 +902,7 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(f"Unexpected request: {request.url}") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - with pytest.raises(WeatherContextError, match="request or response validation failed"): + with pytest.raises(WeatherContextError, match="weather forecast failed: ValueError"): await QWeatherProvider( client, authenticator=StaticAuthenticator(), diff --git a/weather_briefing/cli.py b/weather_briefing/cli.py index 255a6275..4f187aac 100644 --- a/weather_briefing/cli.py +++ b/weather_briefing/cli.py @@ -45,6 +45,7 @@ from .weather_context import ( AirQualitySupplementingWeatherProvider, FallbackWeatherContextProvider, + LoggedWeatherContextProvider, OpenMeteoProvider, QWeatherJWTAuthenticator, QWeatherProvider, @@ -280,9 +281,13 @@ def _weather_context_provider( if settings.weather_providers is not None: raise ValueError("Explicit QWeather provider is missing JWT configuration") continue - providers.append(_build_weather_provider(name, settings, client)) + providers.append(LoggedWeatherContextProvider(name, _build_weather_provider(name, settings, client))) if not providers: raise ValueError("No configured weather provider is available") + _LOGGER.info( + "Weather provider order providers=%s", + ",".join(name for name in names if name != "qweather" or _qweather_is_configured(settings)), + ) weather_provider: WeatherContextProvider = ( providers[0] if len(providers) == 1 else FallbackWeatherContextProvider(*providers) ) diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index adfb4bd9..a6453246 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -2,6 +2,7 @@ import base64 import binascii +import logging import time from collections.abc import Callable from dataclasses import replace @@ -18,6 +19,8 @@ parse_datetime_with_default_timezone, ) +_LOGGER = logging.getLogger("weather_briefing.weather_context") + class WeatherContextError(RuntimeError): """Raised when a weather source is unavailable or violates its contract.""" @@ -27,6 +30,44 @@ class WeatherContextProvider(Protocol): async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: ... +class LoggedWeatherContextProvider: + """Record a non-sensitive history of logical weather provider calls.""" + + def __init__(self, name: str, provider: WeatherContextProvider) -> None: + self._name = name + self._provider = provider + + async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: + started_at = time.monotonic() + _LOGGER.info("Weather API call started provider=%s", self._name) + try: + snapshot = await self._provider.fetch(latitude, longitude) + except WeatherContextError as exc: + _LOGGER.warning( + "Weather API call failed provider=%s duration_ms=%d reason=%s", + self._name, + _elapsed_milliseconds(started_at), + exc, + ) + raise + except Exception as exc: + _LOGGER.warning( + "Weather API call failed provider=%s duration_ms=%d reason=%s", + self._name, + _elapsed_milliseconds(started_at), + type(exc).__name__, + ) + raise + _LOGGER.info( + "Weather API call succeeded provider=%s duration_ms=%d source_id=%s observed_at=%s", + self._name, + _elapsed_milliseconds(started_at), + snapshot.source_id, + snapshot.observed_at.to_iso8601_string(), + ) + return snapshot + + class QWeatherAuthenticator(Protocol): def authorization_header(self) -> str: ... @@ -84,8 +125,10 @@ def __init__( ) async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: + operation = "authentication" try: headers = {"Authorization": self._authenticator.authorization_header()} + operation = "weather forecast" weather_response = await self._client.get( f"{self._base_url}/v7/weather/3d", params={ @@ -98,11 +141,15 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh weather_response.raise_for_status() weather_payload = weather_response.json() if weather_payload.get("code") != "200": - raise WeatherContextError("QWeather returned a non-success weather status") + raise WeatherContextError( + "QWeather returned a non-success weather status " + f"code={_safe_api_status(weather_payload.get('code'))}" + ) weather_forecast = tuple(_format_qweather_day(item) for item in weather_payload.get("daily", ())[:2]) if not weather_forecast: raise WeatherContextError("QWeather returned no daily forecast") + operation = "lifestyle indices" indices_response = await self._client.get( f"{self._base_url}/v7/indices/1d", params={ @@ -115,7 +162,10 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh indices_response.raise_for_status() indices_payload = indices_response.json() if indices_payload.get("code") != "200": - raise WeatherContextError("QWeather returned a non-success indices status") + raise WeatherContextError( + "QWeather returned a non-success indices status " + f"code={_safe_api_status(indices_payload.get('code'))}" + ) lifestyle_advice = tuple(_format_qweather_lifestyle(item) for item in indices_payload.get("daily", ())) source_url = str( weather_payload.get("fxLink") or indices_payload.get("fxLink") or "https://www.qweather.com/" @@ -127,8 +177,9 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh ) except WeatherContextError: raise - except (httpx.HTTPError, jwt.PyJWTError, KeyError, TypeError, ValueError): - raise WeatherContextError("QWeather request or response validation failed") from None + except (httpx.HTTPError, jwt.PyJWTError, KeyError, TypeError, ValueError) as exc: + detail = _safe_provider_error(exc) + raise WeatherContextError(f"QWeather {operation} failed: {detail}") from None air_quality = await self._fetch_air_quality(latitude, longitude, headers) return WeatherContextSnapshot( @@ -172,7 +223,11 @@ async def _fetch_air_quality( category=str(index.get("category", "未知")), health_guidance=str(advice.get("generalPopulation") or health.get("effect", "")), ) - except (httpx.HTTPError, KeyError, TypeError, ValueError): + except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc: + _LOGGER.warning( + "Weather API optional call failed provider=qweather operation=air-quality reason=%s", + _safe_provider_error(exc), + ) return None @@ -231,8 +286,8 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh ) except WeatherContextError: raise - except (httpx.HTTPError, KeyError, TypeError, ValueError): - raise WeatherContextError("Open-Meteo request or response validation failed") from None + except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc: + raise WeatherContextError(f"Open-Meteo weather forecast failed: {_safe_provider_error(exc)}") from None air_quality = await self._fetch_air_quality(latitude, longitude) return WeatherContextSnapshot( @@ -278,7 +333,11 @@ async def _fetch_air_quality(self, latitude: float, longitude: float) -> AirQual category=category, health_guidance=guidance, ) - except (httpx.HTTPError, KeyError, TypeError, ValueError): + except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc: + _LOGGER.warning( + "Weather API optional call failed provider=open-meteo operation=air-quality reason=%s", + _safe_provider_error(exc), + ) return None @@ -297,6 +356,22 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh return await self._providers[-1].fetch(latitude, longitude) +def _elapsed_milliseconds(started_at: float) -> int: + return round((time.monotonic() - started_at) * 1000) + + +def _safe_provider_error(exc: Exception) -> str: + if isinstance(exc, httpx.HTTPStatusError): + return f"HTTP {exc.response.status_code}" + return type(exc).__name__ + + +def _safe_api_status(value: object) -> str: + if isinstance(value, str) and len(value) == 3 and value.isascii() and value.isdigit(): + return value + return "invalid" + + class AirQualitySupplementingWeatherProvider: def __init__( self,