Preserve uncertain-write evidence and safe transport diagnostics for v0.1.3 [skip ci]
This commit is contained in:
@@ -124,7 +124,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Pigway Frame Flow API",
|
title="Pigway Frame Flow API",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
version="0.1.2",
|
version="0.1.3",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
responses=error_responses,
|
responses=error_responses,
|
||||||
)
|
)
|
||||||
@@ -1000,7 +1000,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
)
|
)
|
||||||
def settings_view():
|
def settings_view():
|
||||||
return SettingsView(
|
return SettingsView(
|
||||||
version="0.1.2",
|
version="0.1.3",
|
||||||
mock=settings.mock,
|
mock=settings.mock,
|
||||||
write_enabled=settings.write_enabled,
|
write_enabled=settings.write_enabled,
|
||||||
api_key_configured=bool(settings.api_key)
|
api_key_configured=bool(settings.api_key)
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ class DisplayService:
|
|||||||
"code": exc.code,
|
"code": exc.code,
|
||||||
"message": exc.message,
|
"message": exc.message,
|
||||||
"request_id": op["request_id"],
|
"request_id": op["request_id"],
|
||||||
"details": {},
|
"details": exc.details,
|
||||||
}
|
}
|
||||||
self.update(
|
self.update(
|
||||||
op["id"], "UNKNOWN" if uncertain else "FAILED", error=error
|
op["id"], "UNKNOWN" if uncertain else "FAILED", error=error
|
||||||
@@ -861,7 +861,20 @@ class DisplayService:
|
|||||||
self.update(
|
self.update(
|
||||||
id,
|
id,
|
||||||
"COMPLETED" if confirmed else "FAILED",
|
"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
|
resolved = True
|
||||||
if confirmed and op["action"] == "display":
|
if confirmed and op["action"] == "display":
|
||||||
|
|||||||
@@ -51,13 +51,30 @@ class SafeTransport:
|
|||||||
timeout=(5, 120 if endpoint == "/upload" else 30),
|
timeout=(5, 120 if endpoint == "/upload" else 30),
|
||||||
allow_redirects=False,
|
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(
|
raise FlowError(
|
||||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||||
"Cloud write outcome is unknown"
|
"Cloud write outcome is unknown"
|
||||||
if write
|
if write
|
||||||
else "Device cloud is unavailable",
|
else "Device cloud is unavailable",
|
||||||
502,
|
502,
|
||||||
|
details={"transport_error": category},
|
||||||
) from None
|
) from None
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
code = (
|
code = (
|
||||||
@@ -73,6 +90,7 @@ class SafeTransport:
|
|||||||
code,
|
code,
|
||||||
"Device cloud request failed",
|
"Device cloud request failed",
|
||||||
429 if code == E.RATE_LIMITED else 502,
|
429 if code == E.RATE_LIMITED else 502,
|
||||||
|
details={"http_status": response.status_code},
|
||||||
)
|
)
|
||||||
if binary:
|
if binary:
|
||||||
if len(response.content) > 20 * 1024 * 1024:
|
if len(response.content) > 20 * 1024 * 1024:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ErrorCode(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class FlowError(Exception):
|
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.code, self.message, self.status = code, message, status
|
||||||
|
self.details = details or {}
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ class SettingsView(BaseModel):
|
|||||||
|
|
||||||
class Health(BaseModel):
|
class Health(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str = "0.1.2"
|
version: str = "0.1.3"
|
||||||
|
|
||||||
|
|
||||||
class Readiness(BaseModel):
|
class Readiness(BaseModel):
|
||||||
@@ -366,7 +366,7 @@ class RestoreRequest(StrictModel):
|
|||||||
|
|
||||||
class Version(BaseModel):
|
class Version(BaseModel):
|
||||||
product: str = "Pigway Frame Flow"
|
product: str = "Pigway Frame Flow"
|
||||||
version: str = "0.1.2"
|
version: str = "0.1.3"
|
||||||
api_version: str = "v1"
|
api_version: str = "v1"
|
||||||
commit: str = "development"
|
commit: str = "development"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||||
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.2</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
|
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.3</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "pigway-frame-flow"
|
name = "pigway-frame-flow"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
requires-python = ">=3.12"
|
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"]
|
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]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -118,3 +118,26 @@ def test_rate_budget():
|
|||||||
c.get_device()
|
c.get_device()
|
||||||
assert e.value.code == "RATE_LIMITED"
|
assert e.value.code == "RATE_LIMITED"
|
||||||
assert s.request.call_count == 18
|
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
|
||||||
|
|||||||
@@ -111,3 +111,43 @@ def test_recovered_upload_preserves_temporary_restore(service, monkeypatch):
|
|||||||
assert service.operation(operation)["status"] == "COMPLETED"
|
assert service.operation(operation)["status"] == "COMPLETED"
|
||||||
assert service.top("living-room")["id"] == operation
|
assert service.top("living-room")["id"] == operation
|
||||||
assert service.state("living-room")["managed_image_id"] == 2
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user