diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index b964b28..f1e3924 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -124,7 +124,7 @@ def create_app(settings=None, start_worker=True): app = FastAPI( title="Pigway Frame Flow API", docs_url=None, - version="0.1.2", + version="0.1.3", lifespan=lifespan, responses=error_responses, ) @@ -1000,7 +1000,7 @@ def create_app(settings=None, start_worker=True): ) def settings_view(): return SettingsView( - version="0.1.2", + version="0.1.3", mock=settings.mock, write_enabled=settings.write_enabled, api_key_configured=bool(settings.api_key) diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 2337492..b55fe46 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -442,7 +442,7 @@ class DisplayService: "code": exc.code, "message": exc.message, "request_id": op["request_id"], - "details": {}, + "details": exc.details, } self.update( op["id"], "UNKNOWN" if uncertain else "FAILED", error=error @@ -861,7 +861,20 @@ class DisplayService: self.update( id, "COMPLETED" if confirmed else "FAILED", - {"image_id": device.current_content.id}, + op["result"], + error=None + if confirmed + else { + "code": E.DISPLAY_NOT_CONFIRMED + if op["action"] == "display" + else E.WRITE_RESULT_UNKNOWN, + "message": "Manually closed unconfirmed write; no write replayed", + "request_id": op["request_id"], + "details": { + "observed_image_id": device.current_content.id, + "previous_error": op["error"], + }, + }, ) resolved = True if confirmed and op["action"] == "display": diff --git a/pigway_frame_flow/drivers/kokonna/client.py b/pigway_frame_flow/drivers/kokonna/client.py index 55911a5..1e7c3d6 100644 --- a/pigway_frame_flow/drivers/kokonna/client.py +++ b/pigway_frame_flow/drivers/kokonna/client.py @@ -51,13 +51,30 @@ class SafeTransport: timeout=(5, 120 if endpoint == "/upload" else 30), allow_redirects=False, ) - except requests.RequestException: + except requests.RequestException as exc: + # Persist only fixed categories: exception text and download URLs may + # contain credentials. Unknown writes must still never be replayed. + category = next( + ( + label + for kind, label in ( + (requests.ConnectTimeout, "connect_timeout"), + (requests.ReadTimeout, "read_timeout"), + (requests.exceptions.SSLError, "tls_error"), + (requests.ConnectionError, "connection_error"), + (requests.Timeout, "timeout"), + ) + if isinstance(exc, kind) + ), + "request_error", + ) raise FlowError( E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE, "Cloud write outcome is unknown" if write else "Device cloud is unavailable", 502, + details={"transport_error": category}, ) from None if response.status_code != 200: code = ( @@ -73,6 +90,7 @@ class SafeTransport: code, "Device cloud request failed", 429 if code == E.RATE_LIMITED else 502, + details={"http_status": response.status_code}, ) if binary: if len(response.content) > 20 * 1024 * 1024: diff --git a/pigway_frame_flow/errors.py b/pigway_frame_flow/errors.py index ff10431..85785eb 100644 --- a/pigway_frame_flow/errors.py +++ b/pigway_frame_flow/errors.py @@ -23,6 +23,7 @@ class ErrorCode(StrEnum): class FlowError(Exception): - def __init__(self, code: ErrorCode, message: str, status: int = 400): + def __init__(self, code: ErrorCode, message: str, status: int = 400, details=None): self.code, self.message, self.status = code, message, status + self.details = details or {} super().__init__(message) diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 04a86bf..cd0647a 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -336,7 +336,7 @@ class SettingsView(BaseModel): class Health(BaseModel): status: str - version: str = "0.1.2" + version: str = "0.1.3" class Readiness(BaseModel): @@ -366,7 +366,7 @@ class RestoreRequest(StrictModel): class Version(BaseModel): product: str = "Pigway Frame Flow" - version: str = "0.1.2" + version: str = "0.1.3" api_version: str = "v1" commit: str = "development" build_time: str = "unknown" diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index 5320e66..8f9698e 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ Pigway Frame Flow -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/pyproject.toml b/pyproject.toml index ff34200..0a4a51d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pigway-frame-flow" -version = "0.1.2" +version = "0.1.3" requires-python = ">=3.12" dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"] [project.optional-dependencies] diff --git a/tests/test_client.py b/tests/test_client.py index 7741f11..4f16722 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -118,3 +118,26 @@ def test_rate_budget(): c.get_device() assert e.value.code == "RATE_LIMITED" assert s.request.call_count == 18 + + +@pytest.mark.parametrize( + "exception,category", + [ + (requests.ConnectTimeout, "connect_timeout"), + (requests.ReadTimeout, "read_timeout"), + (requests.exceptions.SSLError, "tls_error"), + (requests.ConnectionError, "connection_error"), + (requests.Timeout, "timeout"), + (requests.RequestException, "request_error"), + ], +) +def test_transport_failure_records_safe_category_without_replay(exception, category): + s = session() + s.request.side_effect = exception("secret https://host/secret") + c = KokonnaClient("secret", True, s) + with pytest.raises(FlowError) as error: + c.display(1) + assert error.value.code == "WRITE_RESULT_UNKNOWN" + assert error.value.details == {"transport_error": category} + assert "secret" not in str(error.value.details) + assert s.request.call_count == 1 diff --git a/tests/test_upload_reconciliation.py b/tests/test_upload_reconciliation.py index f4404e7..90cd58f 100644 --- a/tests/test_upload_reconciliation.py +++ b/tests/test_upload_reconciliation.py @@ -111,3 +111,43 @@ def test_recovered_upload_preserves_temporary_restore(service, monkeypatch): assert service.operation(operation)["status"] == "COMPLETED" assert service.top("living-room")["id"] == operation assert service.state("living-room")["managed_image_id"] == 2 + + +def test_manual_close_preserves_unknown_upload_evidence(service, monkeypatch): + operation, driver = lose_response(service, monkeypatch, change_display=False) + before = service.operation(operation) + assert before["result"].get("image_id") is None + result = service.reconcile(operation, acknowledge=True) + after = service.operation(operation) + assert result["resolved"] + assert after["status"] == "FAILED" + assert after["result"] == before["result"] + assert after["error"]["code"] == "DISPLAY_NOT_CONFIRMED" + assert after["error"]["details"]["previous_error"] == before["error"] + assert after["error"]["details"]["observed_image_id"] == 1 + assert not service.blocked("living-room") + assert driver.client.uploads == 1 + assert ( + service.db.one("SELECT * FROM history ORDER BY id DESC LIMIT 1")["result"] + == "ACKNOWLEDGED" + ) + + +def test_upload_transport_diagnostics_persist_on_operation(service, monkeypatch): + driver = service.registry.get("living-room") + + def fail(*args): + raise FlowError( + ErrorCode.WRITE_RESULT_UNKNOWN, + "Cloud write outcome is unknown", + 502, + details={"transport_error": "read_timeout"}, + ) + + monkeypatch.setattr(driver, "upload", fail) + operation = submit(service) + service.process_one() + op = service.operation(operation) + assert op["status"] == "UNKNOWN" + assert op["error"]["details"] == {"transport_error": "read_timeout"} + assert op["result"]["upload_intent"]