From f57f28ca9596112f683d80657f4c9391463d3c25 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 11 Jun 2026 17:56:18 +0200 Subject: [PATCH 1/3] fix(webhooks): send payload as JSON object instead of double-encoded string model_dump_json() returns a str, which httpx json= encodes a second time, so consumers received a quoted JSON string instead of an object (Slack rejects it with 400 invalid_payload). Use model_dump(mode="json") and let httpx do the encoding. Also log connection/timeout errors instead of letting them bubble up to callers. --- src/app/api/dependencies.py | 6 ++++-- src/tests/test_dependencies.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/app/api/dependencies.py b/src/app/api/dependencies.py index 66eb4480d..c832c9b84 100644 --- a/src/app/api/dependencies.py +++ b/src/app/api/dependencies.py @@ -8,7 +8,7 @@ from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, SecurityScopes -from httpx import AsyncClient, HTTPStatusError +from httpx import AsyncClient, HTTPStatusError, RequestError from jwt import DecodeError, ExpiredSignatureError, InvalidSignatureError from jwt import decode as jwt_decode from pydantic import BaseModel, ValidationError @@ -135,8 +135,10 @@ async def get_current_user( async def dispatch_webhook(url: str, payload: BaseModel) -> None: async with AsyncClient(timeout=5) as client: try: - response = await client.post(url, json=payload.model_dump_json()) + response = await client.post(url, json=payload.model_dump(mode="json")) response.raise_for_status() logger.info(f"Successfully dispatched to {url}") except HTTPStatusError as e: logger.error(f"Error dispatching webhook to {url}: {e.response.status_code} - {e.response.text}") + except RequestError as e: + logger.error(f"Error dispatching webhook to {url}: {e}") diff --git a/src/tests/test_dependencies.py b/src/tests/test_dependencies.py index ffb37e9ff..142a448e5 100644 --- a/src/tests/test_dependencies.py +++ b/src/tests/test_dependencies.py @@ -1,8 +1,11 @@ +from datetime import datetime + import pytest from fastapi import HTTPException from fastapi.security import SecurityScopes +from pydantic import BaseModel -from app.api.dependencies import get_jwt +from app.api.dependencies import dispatch_webhook, get_jwt from app.core.security import create_access_token @@ -54,3 +57,27 @@ def test_get_jwt(scopes, token, expires_minutes, error_code, expected_payload): payload = get_jwt(SecurityScopes(scopes), token_) if expected_payload is not None: assert payload.model_dump() == expected_payload + + +@pytest.mark.asyncio +async def test_dispatch_webhook_sends_json_object(monkeypatch): + captured = {} + + class _Response: + def raise_for_status(self): + return None + + async def _post(self, url, json=None): # noqa: RUF029 - must match AsyncClient.post's async signature + captured["json"] = json + return _Response() + + monkeypatch.setattr("app.api.dependencies.AsyncClient.post", _post) + + class _Payload(BaseModel): + id: int + created_at: datetime + + await dispatch_webhook("https://example.com/hook", _Payload(id=1, created_at=datetime(2026, 6, 11, 15, 38, 6))) + + # The body must be a JSON object, not a double-encoded JSON string + assert captured["json"] == {"id": 1, "created_at": "2026-06-11T15:38:06"} From 02ab9b685412f473b7fea3687d4aa5a149f55a8a Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 23 Jul 2026 09:41:25 +0200 Subject: [PATCH 2/3] style: migrate noqa comment to ruff:ignore syntax required by ruff 0.15 --- src/tests/test_dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_dependencies.py b/src/tests/test_dependencies.py index 142a448e5..dee41a5e6 100644 --- a/src/tests/test_dependencies.py +++ b/src/tests/test_dependencies.py @@ -67,7 +67,7 @@ class _Response: def raise_for_status(self): return None - async def _post(self, url, json=None): # noqa: RUF029 - must match AsyncClient.post's async signature + async def _post(self, url, json=None): # ruff:ignore[unused-async] - must match AsyncClient.post's async signature captured["json"] = json return _Response() From 4c188cfc4ce04c824168f201265a99a5e3c8d650 Mon Sep 17 00:00:00 2001 From: Mateo Date: Thu, 23 Jul 2026 09:49:53 +0200 Subject: [PATCH 3/3] test: cover the RequestError branch of dispatch_webhook --- src/tests/test_dependencies.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/tests/test_dependencies.py b/src/tests/test_dependencies.py index dee41a5e6..4cb37d2e9 100644 --- a/src/tests/test_dependencies.py +++ b/src/tests/test_dependencies.py @@ -3,6 +3,7 @@ import pytest from fastapi import HTTPException from fastapi.security import SecurityScopes +from httpx import ConnectError from pydantic import BaseModel from app.api.dependencies import dispatch_webhook, get_jwt @@ -81,3 +82,17 @@ class _Payload(BaseModel): # The body must be a JSON object, not a double-encoded JSON string assert captured["json"] == {"id": 1, "created_at": "2026-06-11T15:38:06"} + + +@pytest.mark.asyncio +async def test_dispatch_webhook_swallows_request_errors(monkeypatch): + async def _post(self, url, json=None): # ruff:ignore[unused-async] - must match AsyncClient.post's async signature + raise ConnectError("connection refused") + + monkeypatch.setattr("app.api.dependencies.AsyncClient.post", _post) + + class _Payload(BaseModel): + id: int + + # Best-effort dispatch: a network failure is logged, never raised to the caller + await dispatch_webhook("https://example.com/hook", _Payload(id=1))