cmvr_edge_ai/tests/unit/test_server_api.py

748 lines
26 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
import asyncio
import base64
import logging
import httpx
import pytest
from cmvr_edge_ai.application import create_default_capability_registry
from cmvr_edge_ai.config import load_config_data
from cmvr_edge_ai.contracts import (
DetectionsOutput,
InferenceResponse,
InferenceStatus,
ModelReference,
ScalarOutput,
)
from cmvr_edge_ai.core import HealthReport
from cmvr_edge_ai.server import InvocationBroker
from cmvr_edge_ai.server.api import (
InferenceHttpApi,
_InvalidContentLength,
_read_limited_body,
)
def _config(
*,
max_request_bytes: int = 4096,
request_timeout_s: float = 0.2,
model_id: str = "yolov8n-mobile-phone@1",
bearer_token: str | None = None,
): # type: ignore[no-untyped-def]
return load_config_data(
{
"api_version": "cmvr.edge.ai/v1",
"pipelines": {
"mobile_remote": {
"nodes": {
"source": {"uses": "server.request_source@1"},
}
}
},
"server": {
"enabled": True,
"http": {
"request_timeout_s": request_timeout_s,
"max_request_bytes": max_request_bytes,
"max_image_bytes": min(max_request_bytes, 1024),
**(
{}
if bearer_token is None
else {"bearer_token": bearer_token}
),
},
"routes": {
"detect.mobile_phone": {
"pipeline": "mobile_remote",
"model_id": model_id,
"queue_capacity": 1,
}
},
},
}
)
def _request_payload(*, category: str = "detect.mobile_phone") -> dict[str, object]:
return {
"schema_version": "cmvr.inference-request/v1",
"request_id": "request-1",
"category": category,
"inputs": [
{
"kind": "image",
"name": "image",
"media_type": "image/jpeg",
"encoding": "base64",
"data": base64.b64encode(b"jpeg-bytes").decode("ascii"),
}
],
"parameters": {},
"requested_artifact_roles": [],
}
def _response(
request_id: str,
*,
category: str = "detect.mobile_phone",
model_id: str = "yolov8n-mobile-phone@1",
outputs: tuple[object, ...] | None = None,
) -> InferenceResponse:
return InferenceResponse(
request_id=request_id,
trace_id=request_id,
category=category,
model=ModelReference(model_id=model_id, backend="test"),
status=InferenceStatus.NO_RESULT,
outputs=(
(DetectionsOutput(items=()),)
if outputs is None
else outputs
),
)
def test_http_api_routes_one_request_through_the_broker() -> None:
async def exercise() -> None:
config = _config()
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {
"state": "running",
"pipelines": {
"mobile_remote": {"source": HealthReport(True)}
},
}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async def pipeline() -> None:
item = await broker.receive("detect.mobile_phone")
await broker.complete(
item.request_id,
InferenceResponse(
request_id=item.request_id,
trace_id=item.request_id,
category=item.category,
model=ModelReference(
model_id="yolov8n-mobile-phone@1",
backend="test",
),
status=InferenceStatus.NO_RESULT,
outputs=(DetectionsOutput(items=()),),
),
invocation_token=item.invocation_token,
)
worker = asyncio.create_task(pipeline())
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
response = await client.post("/v1/inference", json=_request_payload())
assert response.status_code == 200
assert response.json()["request_id"] == "request-1"
assert response.json()["status"] == "no_result"
models = await client.get("/v1/models")
assert models.status_code == 200
assert [
item["category"] for item in models.json()["passive_invoke"]
] == ["detect.mobile_phone"]
ready = await client.get("/health/ready")
assert ready.status_code == 200
assert ready.json()["status"] == "ready"
await worker
await broker.close()
asyncio.run(exercise())
def test_http_api_returns_structured_errors_for_invalid_requests() -> None:
async def exercise() -> None:
config = _config()
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {"state": "starting", "pipelines": {}}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
unsupported = await client.post(
"/v1/inference",
json=_request_payload(category="detect.unknown"),
)
assert unsupported.status_code == 404
assert unsupported.json()["error"]["code"] == "UNSUPPORTED_CATEGORY"
malformed = await client.post(
"/v1/inference",
content=b"not-json",
headers={"content-type": "application/json"},
)
assert malformed.status_code == 422
assert malformed.json()["schema_version"] == "cmvr.inference-error/v1"
wrong_type = await client.post(
"/v1/inference",
content=b"{}",
headers={"content-type": "text/plain"},
)
assert wrong_type.status_code == 415
ready = await client.get("/health/ready")
assert ready.status_code == 503
await broker.close()
asyncio.run(exercise())
def test_http_api_rejects_body_before_pydantic_parsing_when_too_large() -> None:
async def exercise() -> None:
config = _config(max_request_bytes=1024)
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {"state": "running", "pipelines": {}}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
response = await client.post(
"/v1/inference",
content=b"x" * 1025,
headers={"content-type": "application/json"},
)
assert response.status_code == 413
assert response.json()["error"]["code"] == "REQUEST_TOO_LARGE"
await broker.close()
asyncio.run(exercise())
def test_http_api_requires_configured_bearer_token_except_for_health() -> None:
async def exercise() -> None:
config = _config(bearer_token="deployment-secret")
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {
"state": "running",
"pipelines": {
"mobile_remote": {"source": HealthReport(True)}
},
}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
live = await client.get("/health/live")
ready = await client.get("/health/ready")
missing = await client.get("/v1/models")
wrong = await client.get(
"/v1/models",
headers={"Authorization": "Bearer wrong-secret"},
)
openapi_without_token = await client.get("/openapi.json")
malformed_without_token = await client.post(
"/v1/inference",
json={},
)
authorized = await client.get(
"/v1/models",
headers={"Authorization": "bearer deployment-secret"},
)
openapi_with_token = await client.get(
"/openapi.json",
headers={"Authorization": "Bearer deployment-secret"},
)
malformed_with_token = await client.post(
"/v1/inference",
json={},
headers={"Authorization": "Bearer deployment-secret"},
)
await broker.close()
assert live.status_code == 200
assert ready.status_code == 200
for response in (
missing,
wrong,
openapi_without_token,
malformed_without_token,
):
assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
assert response.json()["error"]["code"] == "UNAUTHORIZED"
assert authorized.status_code == 200
assert openapi_with_token.status_code == 200
assert malformed_with_token.status_code == 422
asyncio.run(exercise())
@pytest.mark.parametrize("content_length", ["invalid", "-1", "1, 2"])
def test_limited_body_rejects_invalid_content_length(
content_length: str,
) -> None:
class Request:
headers = {"content-length": content_length}
async def stream(self): # type: ignore[no-untyped-def]
yield b"{}"
with pytest.raises(_InvalidContentLength):
asyncio.run(_read_limited_body(Request(), 1024))
def test_http_api_generates_and_echoes_request_id_when_client_omits_it() -> None:
async def exercise() -> str:
config = _config()
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {"state": "running", "pipelines": {}}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async def pipeline() -> None:
item = await broker.receive("detect.mobile_phone")
assert item.payload.request_id == item.request_id
await broker.complete(
item.request_id,
_response(item.request_id),
invocation_token=item.invocation_token,
)
payload = _request_payload()
del payload["request_id"]
worker = asyncio.create_task(pipeline())
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
response = await client.post("/v1/inference", json=payload)
await worker
await broker.close()
assert response.status_code == 200
return response.json()["request_id"]
request_id = asyncio.run(exercise())
assert len(request_id) == 32
assert all(character in "0123456789abcdef" for character in request_id)
def test_http_api_rejects_pipeline_responses_for_another_request_or_model(
caplog: pytest.LogCaptureFixture,
) -> None:
async def exercise() -> list[dict[str, object]]:
cases = (
lambda request_id: _response("another-request"),
lambda request_id: _response(
request_id,
category="detect.analog_gauge",
),
lambda request_id: _response(
request_id,
model_id="another-model@1",
),
lambda request_id: _response(
request_id,
outputs=(ScalarOutput(name="reading", value=1.0),),
),
lambda request_id: {"request_id": request_id},
)
errors: list[dict[str, object]] = []
for index, build_response in enumerate(cases):
config = _config()
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {"state": "running", "pipelines": {}}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async def pipeline() -> None:
item = await broker.receive("detect.mobile_phone")
await broker.complete(
item.request_id,
build_response(item.request_id),
invocation_token=item.invocation_token,
)
payload = _request_payload()
payload["request_id"] = f"mismatch-{index}"
worker = asyncio.create_task(pipeline())
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
response = await client.post("/v1/inference", json=payload)
await worker
await broker.close()
assert response.status_code == 500
body = response.json()
assert body["request_id"] == f"mismatch-{index}"
assert body["category"] == "detect.mobile_phone"
assert body["error"]["code"] == "INVALID_PIPELINE_RESPONSE"
errors.append(body)
return errors
caplog.set_level(logging.ERROR, logger="cmvr_edge_ai.server.api")
asyncio.run(exercise())
assert "response request_id does not match" in caplog.text
assert "response category does not match" in caplog.text
assert "response model_id does not match" in caplog.text
assert "unsupported output kind" in caplog.text
assert "unexpected response type dict" in caplog.text
def test_http_api_maps_broker_overload_duplicate_timeout_and_close() -> None:
async def exercise() -> tuple[dict[str, object], ...]:
config = _config(request_timeout_s=0.01)
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {"state": "running", "pipelines": {}}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
await broker.submit("detect.mobile_phone", "duplicate", object())
duplicate_payload = _request_payload()
duplicate_payload["request_id"] = "duplicate"
duplicate = await client.post(
"/v1/inference",
json=duplicate_payload,
)
await broker.cancel("duplicate")
await broker.submit("detect.mobile_phone", "blocker", object())
busy_payload = _request_payload()
busy_payload["request_id"] = "busy"
busy = await client.post("/v1/inference", json=busy_payload)
await broker.cancel("blocker")
timeout_payload = _request_payload()
timeout_payload["request_id"] = "timeout"
timeout = await client.post("/v1/inference", json=timeout_payload)
await broker.close()
closed_payload = _request_payload()
closed_payload["request_id"] = "closed"
closed = await client.post("/v1/inference", json=closed_payload)
assert duplicate.status_code == 409
assert busy.status_code == 429
assert timeout.status_code == 504
assert closed.status_code == 503
return tuple(
response.json()
for response in (duplicate, busy, timeout, closed)
)
duplicate, busy, timeout, closed = asyncio.run(exercise())
assert duplicate["error"]["code"] == "DUPLICATE_REQUEST_ID"
assert busy["error"]["code"] == "BUSY"
assert busy["error"]["retryable"] is True
assert timeout["error"]["code"] == "TIMEOUT"
assert timeout["error"]["retryable"] is True
assert closed["error"]["code"] == "MODEL_UNAVAILABLE"
def test_configured_route_missing_from_broker_is_server_misconfiguration() -> None:
async def exercise() -> tuple[int, dict[str, object]]:
config = _config()
broker = InvocationBroker()
async def health(): # type: ignore[no-untyped-def]
return {"state": "running", "pipelines": {}}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
response = await client.post("/v1/inference", json=_request_payload())
await broker.close()
return response.status_code, response.json()
status_code, body = asyncio.run(exercise())
assert status_code == 503
assert body["error"]["code"] == "ROUTE_MISCONFIGURED"
assert body["request_id"] == "request-1"
def test_pipeline_exception_is_logged_but_not_exposed_to_client(
caplog: pytest.LogCaptureFixture,
) -> None:
async def exercise() -> tuple[int, dict[str, object]]:
config = _config()
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {"state": "running", "pipelines": {}}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async def pipeline() -> None:
item = await broker.receive("detect.mobile_phone")
await broker.fail(
item.request_id,
RuntimeError("private adapter path /tmp/private-model.pt"),
invocation_token=item.invocation_token,
)
worker = asyncio.create_task(pipeline())
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
response = await client.post("/v1/inference", json=_request_payload())
await worker
await broker.close()
return response.status_code, response.json()
caplog.set_level(logging.ERROR, logger="cmvr_edge_ai.server.api")
status_code, body = asyncio.run(exercise())
assert status_code == 500
assert body["error"]["code"] == "INFERENCE_FAILED"
assert "private-model.pt" not in str(body)
assert "request_id=request-1" in caplog.text
assert "private-model.pt" in caplog.text
def test_health_failure_keeps_liveness_and_catalog_available_with_unavailable_status(
caplog: pytest.LogCaptureFixture,
) -> None:
async def exercise() -> tuple[object, object, object]:
config = _config()
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
raise RuntimeError("health backend failed")
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
live = await client.get("/health/live")
ready = await client.get("/health/ready")
models = await client.get("/v1/models")
await broker.close()
return live, ready, models
caplog.set_level(logging.ERROR, logger="cmvr_edge_ai.server.api")
live, ready, models = asyncio.run(exercise())
assert live.status_code == 200
assert ready.status_code == 503
assert ready.json() == {
"status": "not_ready",
"application_state": "unknown",
}
assert models.status_code == 200
assert (
models.json()["passive_invoke"][0]["deployment_status"]
== "unavailable"
)
assert caplog.text.count("health provider failed") == 2
def test_ready_and_catalog_accept_wire_safe_health_reports() -> None:
async def exercise() -> tuple[object, object]:
config = _config()
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {
"state": "running",
"pipelines": {
"mobile_remote": {
"source": {"healthy": True, "detail": "ready"}
}
},
}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
ready = await client.get("/health/ready")
models = await client.get("/v1/models")
await broker.close()
return ready, models
ready, models = asyncio.run(exercise())
assert ready.status_code == 200
assert models.status_code == 200
assert models.json()["passive_invoke"][0]["deployment_status"] == "ready"
def test_closed_broker_makes_readiness_and_passive_catalog_unavailable() -> None:
async def exercise() -> tuple[object, object]:
config = _config()
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
await broker.close()
async def health(): # type: ignore[no-untyped-def]
return {
"state": "running",
"pipelines": {
"mobile_remote": {"source": HealthReport(True)}
},
}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
ready = await client.get("/health/ready")
models = await client.get("/v1/models")
return ready, models
ready, models = asyncio.run(exercise())
assert ready.status_code == 503
assert models.status_code == 200
assert (
models.json()["passive_invoke"][0]["deployment_status"]
== "unavailable"
)
def test_unknown_capability_returns_structured_catalog_and_route_errors(
caplog: pytest.LogCaptureFixture,
) -> None:
async def exercise() -> tuple[object, object]:
config = _config(model_id="not-installed@1")
broker = InvocationBroker()
broker.register_route("detect.mobile_phone", capacity=1)
async def health(): # type: ignore[no-untyped-def]
return {"state": "running", "pipelines": {}}
api = InferenceHttpApi(
config,
create_default_capability_registry(discover_entry_points=False),
broker,
health_provider=health,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=api.app),
base_url="http://test",
) as client:
models = await client.get("/v1/models")
inference = await client.post(
"/v1/inference",
json=_request_payload(),
)
await broker.close()
return models, inference
caplog.set_level(logging.ERROR, logger="cmvr_edge_ai.server.api")
models, inference = asyncio.run(exercise())
assert models.status_code == 503
assert models.json()["error"]["code"] == "CATALOG_UNAVAILABLE"
assert models.json()["schema_version"] == "cmvr.inference-error/v1"
assert inference.status_code == 503
assert inference.json()["error"]["code"] == "ROUTE_MISCONFIGURED"
assert "not-installed@1" not in str(inference.json())
assert "failed to build deployed model catalog" in caplog.text