2026-07-20 16:59:37 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import base64
|
|
|
|
|
from copy import deepcopy
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
import cmvr_edge_ai.connectors.platform.http_json as http_json
|
|
|
|
|
from cmvr_edge_ai.connectors.platform.http_json import PlatformHttpJsonSink
|
|
|
|
|
from cmvr_edge_ai.contracts import (
|
|
|
|
|
BoundingBox,
|
|
|
|
|
Detection,
|
|
|
|
|
DetectionAlert,
|
|
|
|
|
DetectionResult,
|
|
|
|
|
EncodedImage,
|
|
|
|
|
ImageFrame,
|
|
|
|
|
)
|
|
|
|
|
from cmvr_edge_ai.core.component import ComponentContext
|
|
|
|
|
from cmvr_edge_ai.core.envelope import Envelope
|
|
|
|
|
from cmvr_edge_ai.transports.http import HttpClientPool, HttpClientSettings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeHttpStatusError(RuntimeError):
|
|
|
|
|
def __init__(self, status_code: int) -> None:
|
|
|
|
|
self.status_code = status_code
|
|
|
|
|
super().__init__(f"HTTP status {status_code}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class FakeResponse:
|
|
|
|
|
status_code: int
|
|
|
|
|
|
|
|
|
|
def raise_for_status(self) -> None:
|
|
|
|
|
if self.status_code >= 400:
|
|
|
|
|
raise FakeHttpStatusError(self.status_code)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeHttpClient:
|
|
|
|
|
def __init__(self, outcomes: list[FakeResponse | BaseException]) -> None:
|
|
|
|
|
self._outcomes = list(outcomes)
|
|
|
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
async def post(
|
|
|
|
|
self,
|
|
|
|
|
path: str,
|
|
|
|
|
*,
|
|
|
|
|
json: dict[str, Any],
|
|
|
|
|
headers: dict[str, str],
|
|
|
|
|
) -> FakeResponse:
|
|
|
|
|
self.calls.append(
|
|
|
|
|
{
|
|
|
|
|
"path": path,
|
|
|
|
|
"json": deepcopy(json),
|
|
|
|
|
"body_id": id(json),
|
|
|
|
|
"headers": dict(headers),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
if not self._outcomes:
|
|
|
|
|
raise AssertionError("fake HTTP client has no configured outcome")
|
|
|
|
|
outcome = self._outcomes.pop(0)
|
|
|
|
|
if isinstance(outcome, BaseException):
|
|
|
|
|
raise outcome
|
|
|
|
|
return outcome
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeHttpClientPool(HttpClientPool):
|
|
|
|
|
def __init__(self, client: FakeHttpClient) -> None:
|
|
|
|
|
self.client = client
|
|
|
|
|
self.get_calls: list[tuple[str, HttpClientSettings]] = []
|
|
|
|
|
|
|
|
|
|
async def get(
|
|
|
|
|
self, endpoint_id: str, settings: HttpClientSettings
|
|
|
|
|
) -> FakeHttpClient:
|
|
|
|
|
self.get_calls.append((endpoint_id, settings))
|
|
|
|
|
return self.client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _alert(
|
|
|
|
|
event_id: str = "ppe-event-123",
|
|
|
|
|
*,
|
|
|
|
|
image: EncodedImage | None = None,
|
|
|
|
|
) -> DetectionAlert:
|
|
|
|
|
detection = Detection(
|
|
|
|
|
label="No-Helmet",
|
|
|
|
|
confidence=0.94,
|
|
|
|
|
box=BoundingBox(x_min=10.0, y_min=20.0, x_max=100.0, y_max=200.0),
|
|
|
|
|
)
|
|
|
|
|
return DetectionAlert(
|
|
|
|
|
event_id=event_id,
|
|
|
|
|
rule_id="no-helmet-rule",
|
|
|
|
|
model_id="construction-ppe@1",
|
|
|
|
|
model_name="Construction PPE",
|
|
|
|
|
labels=("No-Helmet",),
|
|
|
|
|
scope="source",
|
|
|
|
|
scope_id="right-camera",
|
|
|
|
|
hit_count=3,
|
|
|
|
|
window_ms=2_000.0,
|
|
|
|
|
first_seen_ns=1_000_000_000,
|
|
|
|
|
last_seen_ns=2_000_000_000,
|
|
|
|
|
triggered_at_ns=2_000_000_000,
|
|
|
|
|
max_confidence=0.94,
|
|
|
|
|
detections=(detection,),
|
|
|
|
|
image=image,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _envelope(payload: Any, *, trace_id: str = "trace-fallback-456") -> Envelope[Any]:
|
|
|
|
|
return Envelope(
|
|
|
|
|
payload=payload,
|
|
|
|
|
schema_name=(
|
|
|
|
|
"DetectionAlert" if isinstance(payload, DetectionAlert) else "opaque"
|
|
|
|
|
),
|
|
|
|
|
schema_version=1,
|
|
|
|
|
source_id="right-camera",
|
|
|
|
|
sequence=17,
|
|
|
|
|
captured_at_ns=2_000_000_000,
|
|
|
|
|
received_at_ns=2_010_000_000,
|
|
|
|
|
trace_id=trace_id,
|
|
|
|
|
attributes={"site": "park-a"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _setup_sink(
|
|
|
|
|
client: FakeHttpClient,
|
2026-07-22 15:17:34 +08:00
|
|
|
*,
|
|
|
|
|
context_endpoints: dict[str, dict[str, Any]] | None = None,
|
2026-07-20 16:59:37 +08:00
|
|
|
**params: Any,
|
|
|
|
|
) -> PlatformHttpJsonSink:
|
|
|
|
|
sink = PlatformHttpJsonSink(
|
|
|
|
|
"platform",
|
|
|
|
|
{
|
|
|
|
|
"endpoint": "platform",
|
|
|
|
|
"path": "/v1/detection-alerts",
|
|
|
|
|
**params,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
pool = FakeHttpClientPool(client)
|
2026-07-22 15:17:34 +08:00
|
|
|
endpoints = context_endpoints or {
|
|
|
|
|
"platform": {
|
|
|
|
|
"transport": "http",
|
|
|
|
|
"base_url": "http://platform.invalid",
|
|
|
|
|
"timeout_s": 1.0,
|
|
|
|
|
"metadata": {},
|
|
|
|
|
"options": {},
|
|
|
|
|
},
|
|
|
|
|
"cmvr_es": {
|
|
|
|
|
"transport": "grpc",
|
|
|
|
|
"target": "192.168.0.119:50052",
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-07-20 16:59:37 +08:00
|
|
|
context = ComponentContext(
|
|
|
|
|
pipeline_id="detection",
|
|
|
|
|
node_id="platform",
|
|
|
|
|
shutdown_event=asyncio.Event(),
|
|
|
|
|
metadata={
|
2026-07-22 15:17:34 +08:00
|
|
|
"endpoints": endpoints,
|
2026-07-20 16:59:37 +08:00
|
|
|
"http_client_pool": pool,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
await sink.setup(context)
|
|
|
|
|
assert pool.get_calls[0][0] == "platform"
|
|
|
|
|
return sink
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detection_alert_event_id_is_the_idempotency_key() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([FakeResponse(200)])
|
|
|
|
|
sink = await _setup_sink(client, max_attempts=1)
|
|
|
|
|
|
|
|
|
|
await sink.consume(_envelope(_alert()), input_port="alerts")
|
|
|
|
|
|
|
|
|
|
assert len(client.calls) == 1
|
|
|
|
|
call = client.calls[0]
|
|
|
|
|
assert call["headers"] == {"Idempotency-Key": "ppe-event-123"}
|
|
|
|
|
assert call["path"] == "/v1/detection-alerts"
|
|
|
|
|
assert call["json"]["input_port"] == "alerts"
|
|
|
|
|
assert call["json"]["payload"]["event_id"] == "ppe-event-123"
|
|
|
|
|
assert call["json"]["payload"]["image"] is None
|
2026-07-22 15:17:34 +08:00
|
|
|
assert "grpc_ip" not in call["json"]
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_configured_grpc_endpoint_ip_is_added_to_the_envelope_json() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([FakeResponse(200)])
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
|
|
|
|
grpc_endpoint="cmvr_es",
|
|
|
|
|
max_attempts=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await sink.consume(_envelope(_alert()), input_port="alerts")
|
|
|
|
|
|
|
|
|
|
body = client.calls[0]["json"]
|
|
|
|
|
assert body["grpc_ip"] == "192.168.0.119"
|
|
|
|
|
assert body["source_id"] == "right-camera"
|
|
|
|
|
assert body["payload"]["event_id"] == "ppe-event-123"
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("target", "expected_ip"),
|
|
|
|
|
[
|
|
|
|
|
("10.20.30.40:50052", "10.20.30.40"),
|
|
|
|
|
("ipv4:10.20.30.40:50052", "10.20.30.40"),
|
|
|
|
|
("dns:///10.20.30.40:50052", "10.20.30.40"),
|
|
|
|
|
("[2001:db8::10]:50052", "2001:db8::10"),
|
|
|
|
|
("ipv6:[2001:db8::10]:50052", "2001:db8::10"),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_grpc_target_ip_parser_accepts_literal_ip_targets(
|
|
|
|
|
target: str,
|
|
|
|
|
expected_ip: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
assert http_json._grpc_target_ip(target) == expected_ip
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"target",
|
|
|
|
|
[
|
|
|
|
|
"cmvr-es.local:50052",
|
|
|
|
|
"192.168.0.119",
|
|
|
|
|
"192.168.0.119:not-a-port",
|
|
|
|
|
"192.168.0.119:0",
|
|
|
|
|
"192.168.0.119:65536",
|
|
|
|
|
"2001:db8::10:50052",
|
|
|
|
|
"unix:///run/cmvr-es.sock",
|
|
|
|
|
"ipv4:[2001:db8::10]:50052",
|
|
|
|
|
"ipv6:10.20.30.40:50052",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_grpc_target_ip_parser_rejects_non_literal_or_ambiguous_targets(
|
|
|
|
|
target: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
with pytest.raises(ValueError, match="must contain one literal IP and port"):
|
|
|
|
|
http_json._grpc_target_ip(target)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("grpc_endpoint", "endpoint_config", "message"),
|
|
|
|
|
[
|
|
|
|
|
("missing", None, "unknown endpoint"),
|
|
|
|
|
(
|
|
|
|
|
"not_grpc",
|
|
|
|
|
{"transport": "http", "base_url": "http://example.invalid"},
|
|
|
|
|
"must use grpc transport",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"bad_target",
|
|
|
|
|
{"transport": "grpc", "target": "cmvr-es.local:50052"},
|
|
|
|
|
"must contain one literal IP and port",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_grpc_endpoint_configuration_is_checked_during_setup(
|
|
|
|
|
grpc_endpoint: str,
|
|
|
|
|
endpoint_config: dict[str, Any] | None,
|
|
|
|
|
message: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
endpoints: dict[str, dict[str, Any]] = {
|
|
|
|
|
"platform": {
|
|
|
|
|
"transport": "http",
|
|
|
|
|
"base_url": "http://platform.invalid",
|
|
|
|
|
"timeout_s": 1.0,
|
|
|
|
|
"metadata": {},
|
|
|
|
|
"options": {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if endpoint_config is not None:
|
|
|
|
|
endpoints[grpc_endpoint] = endpoint_config
|
|
|
|
|
with pytest.raises(ValueError, match=message):
|
|
|
|
|
await _setup_sink(
|
|
|
|
|
FakeHttpClient([FakeResponse(200)]),
|
|
|
|
|
context_endpoints=endpoints,
|
|
|
|
|
grpc_endpoint=grpc_endpoint,
|
|
|
|
|
)
|
2026-07-20 16:59:37 +08:00
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detection_alert_image_has_a_flat_base64_wire_shape() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
jpeg = b"\xff\xd8\xff\xe0annotated-image\xff\xd9"
|
|
|
|
|
image = EncodedImage(
|
|
|
|
|
data=jpeg,
|
|
|
|
|
media_type="image/jpeg",
|
|
|
|
|
width=1280,
|
|
|
|
|
height=720,
|
|
|
|
|
)
|
|
|
|
|
client = FakeHttpClient([FakeResponse(200)])
|
|
|
|
|
sink = await _setup_sink(client, max_attempts=1)
|
|
|
|
|
|
|
|
|
|
await sink.consume(_envelope(_alert(image=image)), input_port="alerts")
|
|
|
|
|
|
|
|
|
|
body = client.calls[0]["json"]
|
|
|
|
|
payload = body["payload"]
|
|
|
|
|
assert payload["image"] == {
|
|
|
|
|
"media_type": "image/jpeg",
|
|
|
|
|
"width": 1280,
|
|
|
|
|
"height": 720,
|
|
|
|
|
"encoding": "base64",
|
|
|
|
|
"data": base64.b64encode(jpeg).decode("ascii"),
|
|
|
|
|
}
|
|
|
|
|
assert (
|
|
|
|
|
base64.b64decode(payload["image"]["data"], validate=True) == jpeg
|
|
|
|
|
)
|
|
|
|
|
assert payload["detections"][0] == {
|
|
|
|
|
"label": "No-Helmet",
|
|
|
|
|
"confidence": 0.94,
|
|
|
|
|
"box": {
|
|
|
|
|
"x_min": 10.0,
|
|
|
|
|
"y_min": 20.0,
|
|
|
|
|
"x_max": 100.0,
|
|
|
|
|
"y_max": 200.0,
|
|
|
|
|
},
|
|
|
|
|
"track_id": None,
|
|
|
|
|
}
|
|
|
|
|
# The fake receives the pre-serialization dict; make sure a real JSON
|
|
|
|
|
# encoder can consume the exact body without any remaining bytes.
|
|
|
|
|
json.dumps(body)
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_transient_detection_source_frame_is_never_serialized() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
raw_frame = ImageFrame(bytes(range(12)), 2, 2, "BGR8")
|
|
|
|
|
result = DetectionResult(
|
|
|
|
|
detections=(
|
|
|
|
|
Detection(
|
|
|
|
|
"No-Helmet",
|
|
|
|
|
0.9,
|
|
|
|
|
BoundingBox(1.0, 0.0, 2.0, 2.0),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
model_id="construction-ppe@1",
|
|
|
|
|
inference_ms=3.5,
|
|
|
|
|
source_frame=raw_frame,
|
|
|
|
|
)
|
|
|
|
|
client = FakeHttpClient([FakeResponse(200)])
|
|
|
|
|
sink = await _setup_sink(client, max_attempts=1)
|
|
|
|
|
|
|
|
|
|
await sink.consume(_envelope(result), input_port="detections")
|
|
|
|
|
|
|
|
|
|
payload = client.calls[0]["json"]["payload"]
|
|
|
|
|
assert "source_frame" not in payload
|
|
|
|
|
assert payload["detections"][0]["label"] == "No-Helmet"
|
|
|
|
|
assert base64.b64encode(raw_frame.data).decode("ascii") not in json.dumps(
|
|
|
|
|
client.calls[0]["json"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_payload_without_event_id_falls_back_to_envelope_trace_id() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([FakeResponse(204)])
|
|
|
|
|
sink = await _setup_sink(client, max_attempts=1)
|
|
|
|
|
|
|
|
|
|
await sink.consume(_envelope({"status": "ok"}))
|
|
|
|
|
|
|
|
|
|
assert client.calls[0]["headers"] == {"Idempotency-Key": "trace-fallback-456"}
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"outcomes",
|
|
|
|
|
[
|
|
|
|
|
[FakeResponse(503), FakeResponse(503), FakeResponse(200)],
|
|
|
|
|
[OSError("offline"), TimeoutError("timed out"), FakeResponse(200)],
|
|
|
|
|
],
|
|
|
|
|
ids=("retryable-status", "network-errors"),
|
|
|
|
|
)
|
|
|
|
|
def test_retryable_failures_retry_to_max_attempts_with_an_unchanged_body(
|
|
|
|
|
outcomes: list[FakeResponse | BaseException],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
encode_calls = 0
|
|
|
|
|
original_b64encode = http_json.base64.b64encode
|
|
|
|
|
|
|
|
|
|
def recording_b64encode(value: bytes) -> bytes:
|
|
|
|
|
nonlocal encode_calls
|
|
|
|
|
encode_calls += 1
|
|
|
|
|
return original_b64encode(value)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(http_json.base64, "b64encode", recording_b64encode)
|
|
|
|
|
client = FakeHttpClient(outcomes)
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
2026-07-22 15:17:34 +08:00
|
|
|
grpc_endpoint="cmvr_es",
|
2026-07-20 16:59:37 +08:00
|
|
|
max_attempts=3,
|
|
|
|
|
retry_initial_s=0,
|
|
|
|
|
retry_max_s=0,
|
|
|
|
|
)
|
|
|
|
|
envelope = _envelope(
|
|
|
|
|
_alert(image=EncodedImage(b"jpeg", "image/jpeg", 2, 2))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await sink.consume(envelope)
|
|
|
|
|
|
|
|
|
|
assert len(client.calls) == 3
|
|
|
|
|
first_body = client.calls[0]["json"]
|
|
|
|
|
assert all(call["json"] == first_body for call in client.calls)
|
|
|
|
|
assert len({call["body_id"] for call in client.calls}) == 1
|
2026-07-22 15:17:34 +08:00
|
|
|
assert first_body["grpc_ip"] == "192.168.0.119"
|
2026-07-20 16:59:37 +08:00
|
|
|
assert all(
|
|
|
|
|
call["headers"] == {"Idempotency-Key": "ppe-event-123"}
|
|
|
|
|
for call in client.calls
|
|
|
|
|
)
|
|
|
|
|
assert encode_calls == 1
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_final_retryable_non_success_response_is_raised() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([FakeResponse(503), FakeResponse(503)])
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
|
|
|
|
max_attempts=2,
|
|
|
|
|
retry_initial_s=0,
|
|
|
|
|
retry_max_s=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(FakeHttpStatusError, match="503"):
|
|
|
|
|
await sink.consume(_envelope(_alert()))
|
|
|
|
|
|
|
|
|
|
assert len(client.calls) == 2
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"outcome",
|
|
|
|
|
[FakeResponse(400), ValueError("invalid request body")],
|
|
|
|
|
ids=("non-retryable-status", "non-retryable-exception"),
|
|
|
|
|
)
|
|
|
|
|
def test_non_retryable_failure_is_raised_without_retry(
|
|
|
|
|
outcome: FakeResponse | BaseException,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([outcome, FakeResponse(200)])
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
|
|
|
|
max_attempts=3,
|
|
|
|
|
retry_initial_s=0,
|
|
|
|
|
retry_max_s=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises((FakeHttpStatusError, ValueError)):
|
|
|
|
|
await sink.consume(_envelope(_alert()))
|
|
|
|
|
|
|
|
|
|
assert len(client.calls) == 1
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("outcomes", "expected_attempts", "expected_status"),
|
|
|
|
|
[
|
|
|
|
|
([OSError("offline"), TimeoutError("timed out")], 2, "none"),
|
|
|
|
|
([FakeResponse(503), FakeResponse(503)], 2, "503"),
|
|
|
|
|
([FakeResponse(400)], 1, "400"),
|
|
|
|
|
],
|
|
|
|
|
ids=(
|
|
|
|
|
"network-errors",
|
|
|
|
|
"retryable-status",
|
|
|
|
|
"non-retryable-status",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
def test_log_and_drop_warns_without_failing_the_sink(
|
|
|
|
|
outcomes: list[FakeResponse | BaseException],
|
|
|
|
|
expected_attempts: int,
|
|
|
|
|
expected_status: str,
|
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient(outcomes)
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
|
|
|
|
failure_mode="log_and_drop",
|
|
|
|
|
max_attempts=2,
|
|
|
|
|
retry_initial_s=0,
|
|
|
|
|
retry_max_s=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await sink.consume(_envelope(_alert()), input_port="alerts")
|
|
|
|
|
|
|
|
|
|
assert len(client.calls) == expected_attempts
|
|
|
|
|
|
|
|
|
|
with caplog.at_level(logging.WARNING, logger=http_json.__name__):
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
dropped = [
|
|
|
|
|
record.getMessage()
|
|
|
|
|
for record in caplog.records
|
|
|
|
|
if "HTTP report dropped" in record.getMessage()
|
|
|
|
|
]
|
|
|
|
|
assert len(dropped) == 1
|
|
|
|
|
assert "failure_mode=log_and_drop" in dropped[0]
|
|
|
|
|
assert "action=drop" in dropped[0]
|
|
|
|
|
assert f"attempts={expected_attempts}" in dropped[0]
|
|
|
|
|
assert f"status={expected_status}" in dropped[0]
|
|
|
|
|
assert "trace_id=trace-fallback-456" in dropped[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_log_and_drop_never_swallows_cancellation() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([asyncio.CancelledError()])
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
|
|
|
|
failure_mode="log_and_drop",
|
|
|
|
|
max_attempts=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
|
|
|
await sink.consume(_envelope(_alert()))
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_log_and_drop_continues_with_the_next_envelope() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([OSError("offline"), FakeResponse(204)])
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
|
|
|
|
failure_mode="log_and_drop",
|
|
|
|
|
max_attempts=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await sink.consume(_envelope(_alert("dropped-event")))
|
|
|
|
|
await sink.consume(_envelope(_alert("delivered-event")))
|
|
|
|
|
|
|
|
|
|
assert len(client.calls) == 2
|
|
|
|
|
assert client.calls[0]["headers"] == {
|
|
|
|
|
"Idempotency-Key": "dropped-event"
|
|
|
|
|
}
|
|
|
|
|
assert client.calls[1]["headers"] == {
|
|
|
|
|
"Idempotency-Key": "delivered-event"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_log_and_drop_does_not_swallow_an_unknown_client_error() -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([ValueError("client programming error")])
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
|
|
|
|
failure_mode="log_and_drop",
|
|
|
|
|
max_attempts=3,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="client programming error"):
|
|
|
|
|
await sink.consume(_envelope(_alert()))
|
|
|
|
|
|
|
|
|
|
assert len(client.calls) == 1
|
|
|
|
|
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_log_and_drop_success_does_not_emit_a_dropped_warning(
|
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def scenario() -> None:
|
|
|
|
|
client = FakeHttpClient([FakeResponse(204)])
|
|
|
|
|
sink = await _setup_sink(
|
|
|
|
|
client,
|
|
|
|
|
failure_mode="log_and_drop",
|
|
|
|
|
max_attempts=1,
|
|
|
|
|
)
|
|
|
|
|
await sink.consume(_envelope(_alert()))
|
|
|
|
|
|
|
|
|
|
with caplog.at_level(logging.WARNING, logger=http_json.__name__):
|
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
|
|
|
|
assert not any(
|
|
|
|
|
"HTTP report dropped" in record.getMessage() for record in caplog.records
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("params", "message"),
|
|
|
|
|
[
|
|
|
|
|
({"max_attempts": 0}, "max_attempts must be a positive integer"),
|
|
|
|
|
({"max_attempts": True}, "max_attempts must be a positive integer"),
|
|
|
|
|
({"retry_initial_s": -0.1}, "retry_initial_s must be a non-negative"),
|
|
|
|
|
({"retry_max_s": False}, "retry_max_s must be a non-negative"),
|
|
|
|
|
(
|
|
|
|
|
{"retry_initial_s": 2.0, "retry_max_s": 1.0},
|
|
|
|
|
"retry_max_s must be >= retry_initial_s",
|
|
|
|
|
),
|
|
|
|
|
({"retry_statuses": "503"}, "retry_statuses must be a sequence"),
|
|
|
|
|
({"retry_statuses": [99]}, "must be between 100 and 599"),
|
|
|
|
|
({"retry_statuses": [600]}, "must be between 100 and 599"),
|
|
|
|
|
({"retry_statuses": ["not-a-status"]}, "must be a sequence"),
|
|
|
|
|
({"failure_mode": "warn"}, "must be 'raise' or 'log_and_drop'"),
|
|
|
|
|
({"failure_mode": ""}, "must be 'raise' or 'log_and_drop'"),
|
|
|
|
|
({"failure_mode": None}, "must be 'raise' or 'log_and_drop'"),
|
|
|
|
|
({"failure_mode": True}, "must be 'raise' or 'log_and_drop'"),
|
|
|
|
|
({"failure_mode": []}, "must be 'raise' or 'log_and_drop'"),
|
2026-07-22 15:17:34 +08:00
|
|
|
({"grpc_endpoint": ""}, "grpc_endpoint must be a non-empty"),
|
|
|
|
|
({"grpc_endpoint": " "}, "grpc_endpoint must be a non-empty"),
|
|
|
|
|
({"grpc_endpoint": 42}, "grpc_endpoint must be a non-empty"),
|
2026-07-20 16:59:37 +08:00
|
|
|
],
|
|
|
|
|
)
|
2026-07-22 15:17:34 +08:00
|
|
|
def test_http_sink_configuration_is_validated(
|
|
|
|
|
params: dict[str, Any], message: str
|
|
|
|
|
) -> None:
|
2026-07-20 16:59:37 +08:00
|
|
|
with pytest.raises(ValueError, match=message):
|
|
|
|
|
PlatformHttpJsonSink("platform", {"endpoint": "platform", **params})
|