piphi-runtime-kit-python 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- piphi_runtime_kit_python/__init__.py +180 -0
- piphi_runtime_kit_python/adapters/__init__.py +13 -0
- piphi_runtime_kit_python/adapters/fastapi.py +73 -0
- piphi_runtime_kit_python/fastapi.py +13 -0
- piphi_runtime_kit_python/runtime/__init__.py +124 -0
- piphi_runtime_kit_python/runtime/auth.py +134 -0
- piphi_runtime_kit_python/runtime/config_sync.py +157 -0
- piphi_runtime_kit_python/runtime/configuration.py +112 -0
- piphi_runtime_kit_python/runtime/context.py +29 -0
- piphi_runtime_kit_python/runtime/discovery.py +71 -0
- piphi_runtime_kit_python/runtime/dispatch.py +197 -0
- piphi_runtime_kit_python/runtime/errors.py +131 -0
- piphi_runtime_kit_python/runtime/events.py +194 -0
- piphi_runtime_kit_python/runtime/health.py +54 -0
- piphi_runtime_kit_python/runtime/lifespan.py +79 -0
- piphi_runtime_kit_python/runtime/mqtt.py +213 -0
- piphi_runtime_kit_python/runtime/registry.py +77 -0
- piphi_runtime_kit_python/runtime/starter.py +96 -0
- piphi_runtime_kit_python/runtime/state.py +22 -0
- piphi_runtime_kit_python/runtime/tasks.py +42 -0
- piphi_runtime_kit_python/runtime/telemetry.py +130 -0
- piphi_runtime_kit_python/schemas.py +145 -0
- piphi_runtime_kit_python-0.3.0.dist-info/METADATA +875 -0
- piphi_runtime_kit_python-0.3.0.dist-info/RECORD +27 -0
- piphi_runtime_kit_python-0.3.0.dist-info/WHEEL +4 -0
- piphi_runtime_kit_python-0.3.0.dist-info/entry_points.txt +4 -0
- piphi_runtime_kit_python-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Helpers for typed runtime config handling, safe logging, and responses."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, TypeVar
|
|
6
|
+
|
|
7
|
+
from ..schemas import RuntimeConfig, RuntimeConfigApplyResponse, RuntimeConfigRemoveResponse
|
|
8
|
+
|
|
9
|
+
TConfig = TypeVar("TConfig", bound=RuntimeConfig)
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG_SECRET_KEYS = (
|
|
12
|
+
"password",
|
|
13
|
+
"token",
|
|
14
|
+
"secret",
|
|
15
|
+
"api_key",
|
|
16
|
+
"access_token",
|
|
17
|
+
"refresh_token",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def redact_config_secrets(
|
|
22
|
+
payload: Mapping[str, Any] | None,
|
|
23
|
+
*,
|
|
24
|
+
secret_keys: tuple[str, ...] = DEFAULT_CONFIG_SECRET_KEYS,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Return a copy of a config payload with secret-looking values redacted."""
|
|
27
|
+
if not payload:
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
normalized_secret_keys = {key.lower() for key in secret_keys}
|
|
31
|
+
redacted: dict[str, Any] = {}
|
|
32
|
+
for key, value in payload.items():
|
|
33
|
+
if key.lower() in normalized_secret_keys and value not in (None, ""):
|
|
34
|
+
redacted[key] = "***"
|
|
35
|
+
elif isinstance(value, Mapping):
|
|
36
|
+
redacted[key] = redact_config_secrets(value, secret_keys=secret_keys)
|
|
37
|
+
elif isinstance(value, list):
|
|
38
|
+
redacted[key] = [
|
|
39
|
+
redact_config_secrets(item, secret_keys=secret_keys)
|
|
40
|
+
if isinstance(item, Mapping)
|
|
41
|
+
else item
|
|
42
|
+
for item in value
|
|
43
|
+
]
|
|
44
|
+
else:
|
|
45
|
+
redacted[key] = value
|
|
46
|
+
return redacted
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_config_apply_log(
|
|
50
|
+
payload: RuntimeConfig | Mapping[str, Any],
|
|
51
|
+
*,
|
|
52
|
+
action: str = "config_apply",
|
|
53
|
+
secret_keys: tuple[str, ...] = DEFAULT_CONFIG_SECRET_KEYS,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Build a consistent config lifecycle log message with redacted values."""
|
|
56
|
+
raw_payload = payload.model_dump() if isinstance(payload, RuntimeConfig) else dict(payload)
|
|
57
|
+
config_id = str(raw_payload.get("id") or "missing")
|
|
58
|
+
container_id = str(raw_payload.get("container_id") or "missing")
|
|
59
|
+
redacted_payload = redact_config_secrets(raw_payload, secret_keys=secret_keys)
|
|
60
|
+
return (
|
|
61
|
+
f"{action} "
|
|
62
|
+
f"config_id={config_id} "
|
|
63
|
+
f"container_id={container_id} "
|
|
64
|
+
f"payload={redacted_payload}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_config_apply_response(
|
|
69
|
+
*,
|
|
70
|
+
config_id: str,
|
|
71
|
+
container_id: str | None = None,
|
|
72
|
+
status: str = "configured",
|
|
73
|
+
metadata: dict[str, Any] | None = None,
|
|
74
|
+
) -> RuntimeConfigApplyResponse:
|
|
75
|
+
"""Build the standard response returned after a config is applied."""
|
|
76
|
+
return RuntimeConfigApplyResponse(
|
|
77
|
+
status=status,
|
|
78
|
+
config_id=config_id,
|
|
79
|
+
container_id=container_id,
|
|
80
|
+
metadata=metadata or {},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_config_remove_response(
|
|
85
|
+
*,
|
|
86
|
+
config_id: str,
|
|
87
|
+
removed: bool,
|
|
88
|
+
status: str = "deconfigured",
|
|
89
|
+
metadata: dict[str, Any] | None = None,
|
|
90
|
+
) -> RuntimeConfigRemoveResponse:
|
|
91
|
+
"""Build the standard response returned after a config removal attempt."""
|
|
92
|
+
return RuntimeConfigRemoveResponse(
|
|
93
|
+
status=status,
|
|
94
|
+
config_id=config_id,
|
|
95
|
+
removed=removed,
|
|
96
|
+
metadata=metadata or {},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_typed_config(payload: TConfig | Mapping[str, Any], config_model: type[TConfig]) -> TConfig:
|
|
101
|
+
"""Validate one config payload against an integration-specific RuntimeConfig subclass."""
|
|
102
|
+
if isinstance(payload, config_model):
|
|
103
|
+
return payload
|
|
104
|
+
return config_model.model_validate(payload)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_typed_configs(
|
|
108
|
+
payloads: list[TConfig] | list[Mapping[str, Any]],
|
|
109
|
+
config_model: type[TConfig],
|
|
110
|
+
) -> list[TConfig]:
|
|
111
|
+
"""Validate many config payloads into one typed integration config model."""
|
|
112
|
+
return [validate_typed_config(payload, config_model) for payload in payloads]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Higher-level runtime context that bundles auth and process state together."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .auth import RuntimeAuthContext
|
|
10
|
+
from .state import RuntimeProcessState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class RuntimeContext:
|
|
15
|
+
"""Top-level runtime helper container for simple integrations.
|
|
16
|
+
|
|
17
|
+
This is the easiest entry point when an integration wants one object that
|
|
18
|
+
carries both request/runtime auth state and process-scoped helper state.
|
|
19
|
+
"""
|
|
20
|
+
auth: RuntimeAuthContext = field(default_factory=RuntimeAuthContext)
|
|
21
|
+
process_state: RuntimeProcessState = field(default_factory=RuntimeProcessState)
|
|
22
|
+
|
|
23
|
+
def set_core_http_client(self, client: httpx.AsyncClient | None) -> None:
|
|
24
|
+
"""Attach or clear a shared HTTP client used for Core-bound requests."""
|
|
25
|
+
self.process_state.core_http_client = client
|
|
26
|
+
|
|
27
|
+
def set_current_generation(self, generation: int | None) -> None:
|
|
28
|
+
"""Record the latest applied config generation for config sync flows."""
|
|
29
|
+
self.process_state.current_generation = generation
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Helpers for thin, framework-agnostic discovery request/response handling."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable, Mapping
|
|
6
|
+
|
|
7
|
+
from ..schemas import IntegrationDiscoveryResponse
|
|
8
|
+
|
|
9
|
+
DEFAULT_DISCOVERY_SECRET_KEYS = ("password", "token", "secret", "api_key")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_discovery_inputs(
|
|
13
|
+
inputs: Mapping[str, Any] | None,
|
|
14
|
+
*,
|
|
15
|
+
drop_empty: bool = True,
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Normalize discovery inputs by trimming strings and dropping blank values.
|
|
18
|
+
|
|
19
|
+
This keeps runtime discovery handlers from repeating the same request-shape
|
|
20
|
+
cleanup for optional fields such as usernames, passwords, hostnames, or
|
|
21
|
+
discovery hints.
|
|
22
|
+
"""
|
|
23
|
+
if not inputs:
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
normalized: dict[str, Any] = {}
|
|
27
|
+
for key, value in inputs.items():
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
stripped = value.strip()
|
|
30
|
+
if not stripped and drop_empty:
|
|
31
|
+
continue
|
|
32
|
+
normalized[key] = stripped
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
if value is None and drop_empty:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
normalized[key] = value
|
|
39
|
+
|
|
40
|
+
return normalized
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_discovery_response(
|
|
44
|
+
devices: Iterable[Mapping[str, Any] | dict[str, Any]],
|
|
45
|
+
) -> IntegrationDiscoveryResponse:
|
|
46
|
+
"""Build a standard discovery response from any iterable of device mappings."""
|
|
47
|
+
return IntegrationDiscoveryResponse(devices=[dict(device) for device in devices])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_discovery_attempt_log(
|
|
51
|
+
*,
|
|
52
|
+
inputs: Mapping[str, Any] | None = None,
|
|
53
|
+
secret_keys: Iterable[str] = DEFAULT_DISCOVERY_SECRET_KEYS,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Build a safe log message describing a discovery attempt.
|
|
56
|
+
|
|
57
|
+
Values for secret-looking fields are never logged. Instead, the message
|
|
58
|
+
records which inputs were provided and whether any secret fields were used.
|
|
59
|
+
"""
|
|
60
|
+
normalized_inputs = normalize_discovery_inputs(inputs)
|
|
61
|
+
normalized_secret_keys = {key.lower() for key in secret_keys}
|
|
62
|
+
provided_keys = sorted(normalized_inputs.keys())
|
|
63
|
+
used_secret_keys = sorted(
|
|
64
|
+
key for key in provided_keys if key.lower() in normalized_secret_keys
|
|
65
|
+
)
|
|
66
|
+
return (
|
|
67
|
+
"discovery_attempt "
|
|
68
|
+
f"input_keys={provided_keys} "
|
|
69
|
+
f"uses_secrets={bool(used_secret_keys)} "
|
|
70
|
+
f"secret_keys={used_secret_keys}"
|
|
71
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Helpers for background dispatch of telemetry and event delivery."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Mapping
|
|
6
|
+
|
|
7
|
+
from .events import EventClient
|
|
8
|
+
from .tasks import create_tracked_task
|
|
9
|
+
from .telemetry import TelemetryClient
|
|
10
|
+
from .auth import RuntimeAuthContext
|
|
11
|
+
from .state import RuntimeProcessState
|
|
12
|
+
|
|
13
|
+
EventRecorder = Callable[[dict[str, Any]], Any]
|
|
14
|
+
SkipHandler = Callable[[str, dict[str, Any]], Any]
|
|
15
|
+
ErrorHandler = Callable[[Exception, dict[str, Any]], Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_local_event_record(
|
|
19
|
+
*,
|
|
20
|
+
event_type: str,
|
|
21
|
+
device: Mapping[str, Any],
|
|
22
|
+
payload: Mapping[str, Any] | None = None,
|
|
23
|
+
source: str,
|
|
24
|
+
severity: str,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Build the canonical local runtime event record shape."""
|
|
27
|
+
return {
|
|
28
|
+
"event_type": event_type,
|
|
29
|
+
"source": source,
|
|
30
|
+
"payload": dict(payload or {}),
|
|
31
|
+
"device_id": device.get("device_id"),
|
|
32
|
+
"container_id": device.get("container_id"),
|
|
33
|
+
"integration_id": device.get("integration_id"),
|
|
34
|
+
"config_id": device.get("config_id"),
|
|
35
|
+
"severity": severity,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def dispatch_telemetry_delivery(
|
|
40
|
+
*,
|
|
41
|
+
telemetry_client: TelemetryClient,
|
|
42
|
+
auth_context: RuntimeAuthContext,
|
|
43
|
+
device_id: str,
|
|
44
|
+
metrics: dict[str, Any],
|
|
45
|
+
container_id: str | None = None,
|
|
46
|
+
units: dict[str, Any] | None = None,
|
|
47
|
+
timestamp: str | None = None,
|
|
48
|
+
on_skipped: SkipHandler | None = None,
|
|
49
|
+
on_error: ErrorHandler | None = None,
|
|
50
|
+
) -> bool:
|
|
51
|
+
"""Send telemetry and return whether a delivery attempt succeeded."""
|
|
52
|
+
resolved_container_id, _ = auth_context.resolve(container_id=container_id)
|
|
53
|
+
if not resolved_container_id:
|
|
54
|
+
if on_skipped is not None:
|
|
55
|
+
on_skipped(
|
|
56
|
+
"missing_container_id",
|
|
57
|
+
{"device_id": device_id, "container_id": container_id},
|
|
58
|
+
)
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
await telemetry_client.send_metrics(
|
|
63
|
+
device_id=device_id,
|
|
64
|
+
metrics=metrics,
|
|
65
|
+
auth_context=auth_context,
|
|
66
|
+
container_id=resolved_container_id,
|
|
67
|
+
units=units,
|
|
68
|
+
timestamp=timestamp,
|
|
69
|
+
)
|
|
70
|
+
return True
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
if on_error is not None:
|
|
73
|
+
on_error(exc, {"device_id": device_id, "container_id": resolved_container_id})
|
|
74
|
+
return False
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def schedule_telemetry_delivery(
|
|
79
|
+
*,
|
|
80
|
+
process_state: RuntimeProcessState,
|
|
81
|
+
telemetry_client: TelemetryClient,
|
|
82
|
+
auth_context: RuntimeAuthContext,
|
|
83
|
+
device_id: str,
|
|
84
|
+
metrics: dict[str, Any],
|
|
85
|
+
container_id: str | None = None,
|
|
86
|
+
units: dict[str, Any] | None = None,
|
|
87
|
+
timestamp: str | None = None,
|
|
88
|
+
on_skipped: SkipHandler | None = None,
|
|
89
|
+
on_error: ErrorHandler | None = None,
|
|
90
|
+
):
|
|
91
|
+
"""Schedule background telemetry delivery on the tracked runtime task set."""
|
|
92
|
+
return create_tracked_task(
|
|
93
|
+
dispatch_telemetry_delivery(
|
|
94
|
+
telemetry_client=telemetry_client,
|
|
95
|
+
auth_context=auth_context,
|
|
96
|
+
device_id=device_id,
|
|
97
|
+
metrics=metrics,
|
|
98
|
+
container_id=container_id,
|
|
99
|
+
units=units,
|
|
100
|
+
timestamp=timestamp,
|
|
101
|
+
on_skipped=on_skipped,
|
|
102
|
+
on_error=on_error,
|
|
103
|
+
),
|
|
104
|
+
process_state=process_state,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def dispatch_event_delivery(
|
|
109
|
+
*,
|
|
110
|
+
event_client: EventClient,
|
|
111
|
+
auth_context: RuntimeAuthContext,
|
|
112
|
+
event_type: str,
|
|
113
|
+
device: Mapping[str, Any],
|
|
114
|
+
payload: Mapping[str, Any] | None = None,
|
|
115
|
+
source: str,
|
|
116
|
+
severity: str = "info",
|
|
117
|
+
on_skipped: SkipHandler | None = None,
|
|
118
|
+
on_error: ErrorHandler | None = None,
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""Send one event to Core and return whether delivery succeeded."""
|
|
121
|
+
try:
|
|
122
|
+
event_sent = await event_client.send_event(
|
|
123
|
+
event_type=event_type,
|
|
124
|
+
integration_id=device.get("integration_id"),
|
|
125
|
+
config_id=device.get("config_id") or device.get("device_id"),
|
|
126
|
+
auth_context=auth_context,
|
|
127
|
+
container_id=device.get("container_id"),
|
|
128
|
+
device_id=device.get("device_id"),
|
|
129
|
+
payload=payload,
|
|
130
|
+
source=source,
|
|
131
|
+
severity=severity,
|
|
132
|
+
)
|
|
133
|
+
if not event_sent and on_skipped is not None:
|
|
134
|
+
on_skipped(
|
|
135
|
+
"missing_event_scope",
|
|
136
|
+
{
|
|
137
|
+
"event_type": event_type,
|
|
138
|
+
"device_id": device.get("device_id"),
|
|
139
|
+
"container_id": device.get("container_id"),
|
|
140
|
+
"integration_id": device.get("integration_id"),
|
|
141
|
+
"config_id": device.get("config_id"),
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
return event_sent
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
if on_error is not None:
|
|
147
|
+
on_error(
|
|
148
|
+
exc,
|
|
149
|
+
{
|
|
150
|
+
"event_type": event_type,
|
|
151
|
+
"device_id": device.get("device_id"),
|
|
152
|
+
"container_id": device.get("container_id"),
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
return False
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def schedule_event_delivery(
|
|
160
|
+
*,
|
|
161
|
+
process_state: RuntimeProcessState,
|
|
162
|
+
event_client: EventClient,
|
|
163
|
+
auth_context: RuntimeAuthContext,
|
|
164
|
+
event_type: str,
|
|
165
|
+
device: Mapping[str, Any],
|
|
166
|
+
payload: Mapping[str, Any] | None = None,
|
|
167
|
+
source: str,
|
|
168
|
+
severity: str = "info",
|
|
169
|
+
record_event: EventRecorder | None = None,
|
|
170
|
+
on_skipped: SkipHandler | None = None,
|
|
171
|
+
on_error: ErrorHandler | None = None,
|
|
172
|
+
):
|
|
173
|
+
"""Record an optional local event and schedule delivery to Core."""
|
|
174
|
+
if record_event is not None:
|
|
175
|
+
record_event(
|
|
176
|
+
build_local_event_record(
|
|
177
|
+
event_type=event_type,
|
|
178
|
+
device=device,
|
|
179
|
+
payload=payload,
|
|
180
|
+
source=source,
|
|
181
|
+
severity=severity,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
return create_tracked_task(
|
|
185
|
+
dispatch_event_delivery(
|
|
186
|
+
event_client=event_client,
|
|
187
|
+
auth_context=auth_context,
|
|
188
|
+
event_type=event_type,
|
|
189
|
+
device=device,
|
|
190
|
+
payload=payload,
|
|
191
|
+
source=source,
|
|
192
|
+
severity=severity,
|
|
193
|
+
on_skipped=on_skipped,
|
|
194
|
+
on_error=on_error,
|
|
195
|
+
),
|
|
196
|
+
process_state=process_state,
|
|
197
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Typed delivery errors for PiPhi runtime-to-Core communication."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(eq=False)
|
|
11
|
+
class CoreDeliveryError(Exception):
|
|
12
|
+
"""Base error for failed runtime delivery attempts to PiPhi Core."""
|
|
13
|
+
|
|
14
|
+
operation: str
|
|
15
|
+
url: str
|
|
16
|
+
message: str
|
|
17
|
+
retryable: bool
|
|
18
|
+
status_code: int | None = None
|
|
19
|
+
timeout_seconds: float | None = None
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
details = [self.message, f"operation={self.operation}", f"url={self.url}"]
|
|
23
|
+
if self.status_code is not None:
|
|
24
|
+
details.append(f"status_code={self.status_code}")
|
|
25
|
+
if self.timeout_seconds is not None:
|
|
26
|
+
details.append(f"timeout_seconds={self.timeout_seconds}")
|
|
27
|
+
details.append(f"retryable={self.retryable}")
|
|
28
|
+
return " ".join(details)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(eq=False)
|
|
32
|
+
class CoreUnavailableError(CoreDeliveryError):
|
|
33
|
+
"""Raised when PiPhi Core cannot be reached over the network."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(eq=False)
|
|
37
|
+
class CoreTimeoutError(CoreDeliveryError):
|
|
38
|
+
"""Raised when PiPhi Core does not respond before the configured timeout."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(eq=False)
|
|
42
|
+
class CoreRouteNotFoundError(CoreDeliveryError):
|
|
43
|
+
"""Raised when the expected PiPhi Core route does not exist."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(eq=False)
|
|
47
|
+
class CoreAuthError(CoreDeliveryError):
|
|
48
|
+
"""Raised when PiPhi Core rejects runtime authentication."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(eq=False)
|
|
52
|
+
class CoreServerError(CoreDeliveryError):
|
|
53
|
+
"""Raised when PiPhi Core returns a 5xx response."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(eq=False)
|
|
57
|
+
class CoreUnexpectedResponseError(CoreDeliveryError):
|
|
58
|
+
"""Raised when PiPhi Core returns an unexpected non-success response."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def classify_core_delivery_error(
|
|
62
|
+
*,
|
|
63
|
+
exc: Exception,
|
|
64
|
+
operation: str,
|
|
65
|
+
url: str,
|
|
66
|
+
timeout_seconds: float | None,
|
|
67
|
+
) -> CoreDeliveryError:
|
|
68
|
+
"""Convert raw HTTP client exceptions into stable PiPhi delivery errors."""
|
|
69
|
+
if isinstance(exc, httpx.ReadTimeout):
|
|
70
|
+
return CoreTimeoutError(
|
|
71
|
+
operation=operation,
|
|
72
|
+
url=url,
|
|
73
|
+
message="PiPhi Core did not respond before the request timeout",
|
|
74
|
+
retryable=True,
|
|
75
|
+
timeout_seconds=timeout_seconds,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if isinstance(exc, httpx.ConnectError):
|
|
79
|
+
return CoreUnavailableError(
|
|
80
|
+
operation=operation,
|
|
81
|
+
url=url,
|
|
82
|
+
message="PiPhi Core is unreachable",
|
|
83
|
+
retryable=True,
|
|
84
|
+
timeout_seconds=timeout_seconds,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
88
|
+
status_code = exc.response.status_code
|
|
89
|
+
if status_code == 404:
|
|
90
|
+
return CoreRouteNotFoundError(
|
|
91
|
+
operation=operation,
|
|
92
|
+
url=url,
|
|
93
|
+
message="PiPhi Core route is not available",
|
|
94
|
+
retryable=False,
|
|
95
|
+
status_code=status_code,
|
|
96
|
+
timeout_seconds=timeout_seconds,
|
|
97
|
+
)
|
|
98
|
+
if status_code in {401, 403}:
|
|
99
|
+
return CoreAuthError(
|
|
100
|
+
operation=operation,
|
|
101
|
+
url=url,
|
|
102
|
+
message="PiPhi Core rejected the runtime authentication headers",
|
|
103
|
+
retryable=False,
|
|
104
|
+
status_code=status_code,
|
|
105
|
+
timeout_seconds=timeout_seconds,
|
|
106
|
+
)
|
|
107
|
+
if 500 <= status_code <= 599:
|
|
108
|
+
return CoreServerError(
|
|
109
|
+
operation=operation,
|
|
110
|
+
url=url,
|
|
111
|
+
message="PiPhi Core failed while processing the delivery request",
|
|
112
|
+
retryable=True,
|
|
113
|
+
status_code=status_code,
|
|
114
|
+
timeout_seconds=timeout_seconds,
|
|
115
|
+
)
|
|
116
|
+
return CoreUnexpectedResponseError(
|
|
117
|
+
operation=operation,
|
|
118
|
+
url=url,
|
|
119
|
+
message="PiPhi Core returned an unexpected non-success response",
|
|
120
|
+
retryable=False,
|
|
121
|
+
status_code=status_code,
|
|
122
|
+
timeout_seconds=timeout_seconds,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return CoreUnexpectedResponseError(
|
|
126
|
+
operation=operation,
|
|
127
|
+
url=url,
|
|
128
|
+
message=str(exc) or exc.__class__.__name__,
|
|
129
|
+
retryable=False,
|
|
130
|
+
timeout_seconds=timeout_seconds,
|
|
131
|
+
)
|