piphi-runtime-kit-python 0.4.6__py3-none-any.whl → 0.6.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 +35 -5
- piphi_runtime_kit_python/adapters/fastapi.py +2 -2
- piphi_runtime_kit_python/py.typed +1 -0
- piphi_runtime_kit_python/runtime/__init__.py +25 -3
- piphi_runtime_kit_python/runtime/auth.py +9 -5
- piphi_runtime_kit_python/runtime/config_sync.py +13 -8
- piphi_runtime_kit_python/runtime/configuration.py +13 -5
- piphi_runtime_kit_python/runtime/context.py +3 -2
- piphi_runtime_kit_python/runtime/discovery.py +2 -2
- piphi_runtime_kit_python/runtime/dispatch.py +67 -17
- piphi_runtime_kit_python/runtime/errors.py +4 -4
- piphi_runtime_kit_python/runtime/events.py +42 -7
- piphi_runtime_kit_python/runtime/health.py +5 -2
- piphi_runtime_kit_python/runtime/identity.py +80 -5
- piphi_runtime_kit_python/runtime/lifespan.py +10 -5
- piphi_runtime_kit_python/runtime/mqtt.py +8 -4
- piphi_runtime_kit_python/runtime/registry.py +8 -4
- piphi_runtime_kit_python/runtime/starter.py +40 -12
- piphi_runtime_kit_python/runtime/state.py +10 -2
- piphi_runtime_kit_python/runtime/tasks.py +73 -14
- piphi_runtime_kit_python/runtime/telemetry.py +75 -6
- piphi_runtime_kit_python/schemas.py +25 -3
- {piphi_runtime_kit_python-0.4.6.dist-info → piphi_runtime_kit_python-0.6.0.dist-info}/METADATA +102 -9
- piphi_runtime_kit_python-0.6.0.dist-info/RECORD +29 -0
- {piphi_runtime_kit_python-0.4.6.dist-info → piphi_runtime_kit_python-0.6.0.dist-info}/WHEEL +1 -1
- piphi_runtime_kit_python-0.4.6.dist-info/RECORD +0 -28
- {piphi_runtime_kit_python-0.4.6.dist-info → piphi_runtime_kit_python-0.6.0.dist-info}/entry_points.txt +0 -0
- {piphi_runtime_kit_python-0.4.6.dist-info → piphi_runtime_kit_python-0.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -63,8 +63,17 @@ from .runtime.events import (
|
|
|
63
63
|
format_event_log,
|
|
64
64
|
normalize_event_payload,
|
|
65
65
|
)
|
|
66
|
-
from .runtime.health import
|
|
67
|
-
|
|
66
|
+
from .runtime.health import (
|
|
67
|
+
build_runtime_diagnostics_response,
|
|
68
|
+
build_runtime_health_response,
|
|
69
|
+
)
|
|
70
|
+
from .runtime.identity import (
|
|
71
|
+
RuntimeDeviceRef,
|
|
72
|
+
RuntimeIdentityError,
|
|
73
|
+
build_runtime_identity,
|
|
74
|
+
resolve_config_id,
|
|
75
|
+
resolve_device_id,
|
|
76
|
+
)
|
|
68
77
|
from .runtime.lifespan import (
|
|
69
78
|
DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
70
79
|
DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
|
|
@@ -86,9 +95,24 @@ from .runtime.mqtt import (
|
|
|
86
95
|
)
|
|
87
96
|
from .runtime.registry import RuntimeRegistry
|
|
88
97
|
from .runtime.state import RuntimeProcessState
|
|
89
|
-
from .runtime.starter import
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
from .runtime.starter import (
|
|
99
|
+
RuntimeStarter,
|
|
100
|
+
build_entities_response,
|
|
101
|
+
create_runtime_starter,
|
|
102
|
+
)
|
|
103
|
+
from .runtime.tasks import (
|
|
104
|
+
BackgroundTaskLimitError,
|
|
105
|
+
create_tracked_task,
|
|
106
|
+
shutdown_background_tasks,
|
|
107
|
+
track_background_task,
|
|
108
|
+
)
|
|
109
|
+
from .runtime.telemetry import (
|
|
110
|
+
TelemetryClient,
|
|
111
|
+
TelemetryReading,
|
|
112
|
+
TelemetryValue,
|
|
113
|
+
build_core_auth_headers,
|
|
114
|
+
build_telemetry_maps,
|
|
115
|
+
)
|
|
92
116
|
from .schemas import (
|
|
93
117
|
CoreEventPayload,
|
|
94
118
|
EventSeverity,
|
|
@@ -115,6 +139,7 @@ from .schemas import (
|
|
|
115
139
|
)
|
|
116
140
|
|
|
117
141
|
__all__ = [
|
|
142
|
+
"BackgroundTaskLimitError",
|
|
118
143
|
"ConfigSyncCoordinator",
|
|
119
144
|
"CoreAuthError",
|
|
120
145
|
"CoreDeliveryError",
|
|
@@ -150,12 +175,16 @@ __all__ = [
|
|
|
150
175
|
"RuntimeConfigSnapshot",
|
|
151
176
|
"RuntimeConfigSyncResponse",
|
|
152
177
|
"RuntimeContext",
|
|
178
|
+
"RuntimeDeviceRef",
|
|
179
|
+
"RuntimeIdentityError",
|
|
153
180
|
"RuntimeRegistry",
|
|
154
181
|
"RuntimeProcessState",
|
|
155
182
|
"RuntimeStarter",
|
|
156
183
|
"RuntimeDiagnosticsResponse",
|
|
157
184
|
"RuntimeHealthResponse",
|
|
158
185
|
"TelemetryClient",
|
|
186
|
+
"TelemetryReading",
|
|
187
|
+
"TelemetryValue",
|
|
159
188
|
"EventClient",
|
|
160
189
|
"TelemetryPayload",
|
|
161
190
|
"CoreEventPayload",
|
|
@@ -172,6 +201,7 @@ __all__ = [
|
|
|
172
201
|
"build_local_event_record",
|
|
173
202
|
"build_runtime_identity",
|
|
174
203
|
"build_core_auth_headers",
|
|
204
|
+
"build_telemetry_maps",
|
|
175
205
|
"build_config_apply_response",
|
|
176
206
|
"build_config_remove_response",
|
|
177
207
|
"build_entities_response",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -8,7 +8,11 @@ from .auth import (
|
|
|
8
8
|
format_runtime_auth_sync_log,
|
|
9
9
|
mask_token,
|
|
10
10
|
)
|
|
11
|
-
from .config_sync import
|
|
11
|
+
from .config_sync import (
|
|
12
|
+
ConfigSyncCoordinator,
|
|
13
|
+
build_sync_response,
|
|
14
|
+
reconcile_config_ids,
|
|
15
|
+
)
|
|
12
16
|
from .configuration import (
|
|
13
17
|
build_config_apply_response,
|
|
14
18
|
build_config_remove_response,
|
|
@@ -49,6 +53,7 @@ from .events import (
|
|
|
49
53
|
normalize_event_payload,
|
|
50
54
|
)
|
|
51
55
|
from .health import build_runtime_diagnostics_response, build_runtime_health_response
|
|
56
|
+
from .identity import RuntimeDeviceRef, RuntimeIdentityError
|
|
52
57
|
from .lifespan import (
|
|
53
58
|
DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
54
59
|
DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
|
|
@@ -60,10 +65,22 @@ from .lifespan import (
|
|
|
60
65
|
from .registry import RuntimeRegistry
|
|
61
66
|
from .state import RuntimeProcessState
|
|
62
67
|
from .starter import RuntimeStarter, create_runtime_starter
|
|
63
|
-
from .tasks import
|
|
64
|
-
|
|
68
|
+
from .tasks import (
|
|
69
|
+
BackgroundTaskLimitError,
|
|
70
|
+
create_tracked_task,
|
|
71
|
+
shutdown_background_tasks,
|
|
72
|
+
track_background_task,
|
|
73
|
+
)
|
|
74
|
+
from .telemetry import (
|
|
75
|
+
TelemetryClient,
|
|
76
|
+
TelemetryReading,
|
|
77
|
+
TelemetryValue,
|
|
78
|
+
build_core_auth_headers,
|
|
79
|
+
build_telemetry_maps,
|
|
80
|
+
)
|
|
65
81
|
|
|
66
82
|
__all__ = [
|
|
83
|
+
"BackgroundTaskLimitError",
|
|
67
84
|
"ConfigSyncCoordinator",
|
|
68
85
|
"CoreAuthError",
|
|
69
86
|
"CoreDeliveryError",
|
|
@@ -77,10 +94,14 @@ __all__ = [
|
|
|
77
94
|
"RuntimeAuthContext",
|
|
78
95
|
"RuntimeAuthHeaders",
|
|
79
96
|
"RuntimeContext",
|
|
97
|
+
"RuntimeDeviceRef",
|
|
98
|
+
"RuntimeIdentityError",
|
|
80
99
|
"RuntimeRegistry",
|
|
81
100
|
"RuntimeProcessState",
|
|
82
101
|
"RuntimeStarter",
|
|
83
102
|
"TelemetryClient",
|
|
103
|
+
"TelemetryReading",
|
|
104
|
+
"TelemetryValue",
|
|
84
105
|
"EventClient",
|
|
85
106
|
"build_local_event_record",
|
|
86
107
|
"build_config_apply_response",
|
|
@@ -90,6 +111,7 @@ __all__ = [
|
|
|
90
111
|
"build_event_ingest_response",
|
|
91
112
|
"build_event_list_response",
|
|
92
113
|
"build_core_auth_headers",
|
|
114
|
+
"build_telemetry_maps",
|
|
93
115
|
"build_runtime_auth_headers",
|
|
94
116
|
"build_sync_response",
|
|
95
117
|
"bind_core_http_client",
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
"""Helpers for resolving PiPhi runtime auth across requests and background work."""
|
|
4
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
5
|
import os
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import Any, Mapping
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
DEFAULT_INTERNAL_TOKEN_ENV_NAME = "PIPHI_INTEGRATION_INTERNAL_TOKEN"
|
|
10
|
+
DEFAULT_INTERNAL_TOKEN_ENV_NAME = "PIPHI_INTEGRATION_INTERNAL_TOKEN" # nosec B105
|
|
11
11
|
RUNTIME_CONTAINER_ID_HEADER_NAME = "X-Container-Id"
|
|
12
|
-
RUNTIME_INTERNAL_TOKEN_HEADER_NAME = "X-PiPhi-Integration-Token"
|
|
12
|
+
RUNTIME_INTERNAL_TOKEN_HEADER_NAME = "X-PiPhi-Integration-Token" # nosec B105
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def mask_token(token: str | None) -> str:
|
|
@@ -32,6 +32,7 @@ def _get_header_value(headers: Mapping[str, Any], name: str) -> str:
|
|
|
32
32
|
@dataclass(slots=True, frozen=True)
|
|
33
33
|
class RuntimeAuthHeaders:
|
|
34
34
|
"""Normalized PiPhi runtime auth values extracted from request headers."""
|
|
35
|
+
|
|
35
36
|
container_id: str = ""
|
|
36
37
|
internal_token: str = ""
|
|
37
38
|
|
|
@@ -81,6 +82,7 @@ class RuntimeAuthContext:
|
|
|
81
82
|
Integrations can keep the most recent container scope and internal token here,
|
|
82
83
|
then reuse it for outbound telemetry or config sync calls.
|
|
83
84
|
"""
|
|
85
|
+
|
|
84
86
|
container_id: str = ""
|
|
85
87
|
internal_token: str = ""
|
|
86
88
|
|
|
@@ -130,5 +132,7 @@ class RuntimeAuthContext:
|
|
|
130
132
|
) -> tuple[str, str]:
|
|
131
133
|
"""Resolve the effective container scope and internal token for a request."""
|
|
132
134
|
resolved_container_id = str(container_id or "").strip() or self.container_id
|
|
133
|
-
resolved_internal_token =
|
|
135
|
+
resolved_internal_token = (
|
|
136
|
+
self.internal_token or (os.getenv(token_env_name) or "").strip()
|
|
137
|
+
)
|
|
134
138
|
return resolved_container_id, resolved_internal_token
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
"""Helpers for basic config-sync orchestration and generation tracking."""
|
|
4
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
@@ -36,7 +36,9 @@ def resolve_core_base_url(default: str | None = None) -> str | None:
|
|
|
36
36
|
value = (os.getenv(CORE_BASE_URL_ENV_NAME) or "").strip().rstrip("/")
|
|
37
37
|
if value:
|
|
38
38
|
return value
|
|
39
|
-
return
|
|
39
|
+
return (
|
|
40
|
+
default.rstrip("/") if isinstance(default, str) and default.strip() else default
|
|
41
|
+
)
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
def resolve_runtime_config_snapshot_path(
|
|
@@ -164,7 +166,7 @@ def build_runtime_config_snapshot_from_core_rows(
|
|
|
164
166
|
rows: list[dict[str, Any]],
|
|
165
167
|
*,
|
|
166
168
|
container_id: str,
|
|
167
|
-
config_model: type[TConfig] = RuntimeConfig,
|
|
169
|
+
config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
|
|
168
170
|
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
169
171
|
reason: str = "startup_rehydrate",
|
|
170
172
|
) -> Any:
|
|
@@ -200,7 +202,7 @@ async def fetch_core_runtime_config_snapshot(
|
|
|
200
202
|
runtime_context: RuntimeContext,
|
|
201
203
|
client: httpx.AsyncClient,
|
|
202
204
|
core_base_url: str | None = None,
|
|
203
|
-
config_model: type[TConfig] = RuntimeConfig,
|
|
205
|
+
config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
|
|
204
206
|
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
205
207
|
timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
206
208
|
reason: str = "startup_rehydrate",
|
|
@@ -247,7 +249,7 @@ async def rehydrate_runtime_configs(
|
|
|
247
249
|
runtime_context: RuntimeContext,
|
|
248
250
|
client: httpx.AsyncClient,
|
|
249
251
|
apply_snapshot: SnapshotApplyCallable,
|
|
250
|
-
config_model: type[TConfig] = RuntimeConfig,
|
|
252
|
+
config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
|
|
251
253
|
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
252
254
|
core_base_url: str | None = None,
|
|
253
255
|
timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
@@ -338,7 +340,7 @@ def build_sync_response(
|
|
|
338
340
|
applied: list[str] | None = None,
|
|
339
341
|
removed: list[str] | None = None,
|
|
340
342
|
active_config_ids: list[str] | None = None,
|
|
341
|
-
metadata: dict | None = None,
|
|
343
|
+
metadata: dict[str, Any] | None = None,
|
|
342
344
|
) -> RuntimeConfigSyncResponse:
|
|
343
345
|
"""Build a standard PiPhi config-sync response payload."""
|
|
344
346
|
return RuntimeConfigSyncResponse(
|
|
@@ -360,6 +362,7 @@ class ConfigSyncCoordinator:
|
|
|
360
362
|
This class intentionally stays small. It helps integrations decide whether
|
|
361
363
|
an incoming snapshot is stale and keeps generation tracking in one place.
|
|
362
364
|
"""
|
|
365
|
+
|
|
363
366
|
process_state: RuntimeProcessState
|
|
364
367
|
|
|
365
368
|
def get_current_generation(self) -> int | None:
|
|
@@ -443,7 +446,9 @@ class ConfigSyncCoordinator:
|
|
|
443
446
|
|
|
444
447
|
current_generation = self.mark_generation(snapshot.generation)
|
|
445
448
|
next_active_config_ids = (
|
|
446
|
-
get_active_config_ids()
|
|
449
|
+
get_active_config_ids()
|
|
450
|
+
if get_active_config_ids is not None
|
|
451
|
+
else incoming_ids
|
|
447
452
|
)
|
|
448
453
|
|
|
449
454
|
return build_sync_response(
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
"""Helpers for typed runtime config handling, safe logging, and responses."""
|
|
4
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
5
|
from typing import Any, Mapping, TypeVar
|
|
6
6
|
|
|
7
|
-
from ..schemas import
|
|
7
|
+
from ..schemas import (
|
|
8
|
+
RuntimeConfig,
|
|
9
|
+
RuntimeConfigApplyResponse,
|
|
10
|
+
RuntimeConfigRemoveResponse,
|
|
11
|
+
)
|
|
8
12
|
|
|
9
13
|
TConfig = TypeVar("TConfig", bound=RuntimeConfig)
|
|
10
14
|
|
|
@@ -53,7 +57,9 @@ def format_config_apply_log(
|
|
|
53
57
|
secret_keys: tuple[str, ...] = DEFAULT_CONFIG_SECRET_KEYS,
|
|
54
58
|
) -> str:
|
|
55
59
|
"""Build a consistent config lifecycle log message with redacted values."""
|
|
56
|
-
raw_payload =
|
|
60
|
+
raw_payload = (
|
|
61
|
+
payload.model_dump() if isinstance(payload, RuntimeConfig) else dict(payload)
|
|
62
|
+
)
|
|
57
63
|
config_id = str(raw_payload.get("id") or "missing")
|
|
58
64
|
container_id = str(raw_payload.get("container_id") or "missing")
|
|
59
65
|
redacted_payload = redact_config_secrets(raw_payload, secret_keys=secret_keys)
|
|
@@ -97,7 +103,9 @@ def build_config_remove_response(
|
|
|
97
103
|
)
|
|
98
104
|
|
|
99
105
|
|
|
100
|
-
def validate_typed_config(
|
|
106
|
+
def validate_typed_config(
|
|
107
|
+
payload: TConfig | Mapping[str, Any], config_model: type[TConfig]
|
|
108
|
+
) -> TConfig:
|
|
101
109
|
"""Validate one config payload against an integration-specific RuntimeConfig subclass."""
|
|
102
110
|
if isinstance(payload, config_model):
|
|
103
111
|
return payload
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
"""Higher-level runtime context that bundles auth and process state together."""
|
|
4
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
@@ -17,6 +17,7 @@ class RuntimeContext:
|
|
|
17
17
|
This is the easiest entry point when an integration wants one object that
|
|
18
18
|
carries both request/runtime auth state and process-scoped helper state.
|
|
19
19
|
"""
|
|
20
|
+
|
|
20
21
|
auth: RuntimeAuthContext = field(default_factory=RuntimeAuthContext)
|
|
21
22
|
process_state: RuntimeProcessState = field(default_factory=RuntimeProcessState)
|
|
22
23
|
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
"""Helpers for background dispatch of telemetry and event delivery."""
|
|
4
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
5
|
import asyncio
|
|
6
|
-
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import random
|
|
8
|
+
from collections.abc import Awaitable
|
|
9
|
+
from typing import Any, Callable, Mapping, TypeVar
|
|
10
|
+
from uuid import uuid4
|
|
7
11
|
|
|
12
|
+
from .auth import RuntimeAuthContext
|
|
8
13
|
from .errors import CoreDeliveryError
|
|
9
14
|
from .events import EventClient
|
|
15
|
+
from .state import RuntimeProcessState
|
|
10
16
|
from .tasks import create_tracked_task
|
|
11
17
|
from .telemetry import TelemetryClient
|
|
12
|
-
from .auth import RuntimeAuthContext
|
|
13
|
-
from .state import RuntimeProcessState
|
|
14
18
|
|
|
15
19
|
EventRecorder = Callable[[dict[str, Any]], Any]
|
|
16
20
|
SkipHandler = Callable[[str, dict[str, Any]], Any]
|
|
@@ -18,6 +22,8 @@ ErrorHandler = Callable[[Exception, dict[str, Any]], Any]
|
|
|
18
22
|
|
|
19
23
|
DEFAULT_RETRYABLE_DELIVERY_ATTEMPTS = 5
|
|
20
24
|
DEFAULT_RETRYABLE_DELIVERY_INITIAL_DELAY_SECONDS = 0.5
|
|
25
|
+
DEFAULT_RETRYABLE_DELIVERY_JITTER_RATIO = 0.2
|
|
26
|
+
TDelivery = TypeVar("TDelivery")
|
|
21
27
|
|
|
22
28
|
|
|
23
29
|
def build_local_event_record(
|
|
@@ -27,9 +33,12 @@ def build_local_event_record(
|
|
|
27
33
|
payload: Mapping[str, Any] | None = None,
|
|
28
34
|
source: str,
|
|
29
35
|
severity: str,
|
|
36
|
+
event_id: str | None = None,
|
|
37
|
+
ts: str | dt.datetime | None = None,
|
|
38
|
+
topic: str | None = None,
|
|
30
39
|
) -> dict[str, Any]:
|
|
31
40
|
"""Build the canonical local runtime event record shape."""
|
|
32
|
-
|
|
41
|
+
record = {
|
|
33
42
|
"event_type": event_type,
|
|
34
43
|
"source": source,
|
|
35
44
|
"payload": dict(payload or {}),
|
|
@@ -39,28 +48,35 @@ def build_local_event_record(
|
|
|
39
48
|
"config_id": device.get("config_id"),
|
|
40
49
|
"severity": severity,
|
|
41
50
|
}
|
|
51
|
+
if event_id is not None:
|
|
52
|
+
record["event_id"] = event_id
|
|
53
|
+
if ts is not None:
|
|
54
|
+
record["ts"] = ts.isoformat() if isinstance(ts, dt.datetime) else ts
|
|
55
|
+
if topic is not None:
|
|
56
|
+
record["topic"] = topic
|
|
57
|
+
return record
|
|
42
58
|
|
|
43
59
|
|
|
44
60
|
async def _run_with_retryable_core_delivery_backoff(
|
|
45
|
-
delivery: Callable[[],
|
|
61
|
+
delivery: Callable[[], Awaitable[TDelivery]],
|
|
46
62
|
*,
|
|
47
63
|
max_attempts: int = DEFAULT_RETRYABLE_DELIVERY_ATTEMPTS,
|
|
48
64
|
initial_delay_seconds: float = DEFAULT_RETRYABLE_DELIVERY_INITIAL_DELAY_SECONDS,
|
|
49
|
-
|
|
65
|
+
jitter_ratio: float = DEFAULT_RETRYABLE_DELIVERY_JITTER_RATIO,
|
|
66
|
+
random_value: Callable[[], float] = random.random,
|
|
67
|
+
) -> TDelivery:
|
|
50
68
|
"""Retry transient Core delivery failures with a short exponential backoff."""
|
|
51
69
|
|
|
52
70
|
attempt = 1
|
|
53
71
|
delay_seconds = initial_delay_seconds
|
|
54
72
|
while True:
|
|
55
73
|
try:
|
|
56
|
-
|
|
57
|
-
if asyncio.iscoroutine(result):
|
|
58
|
-
return await result
|
|
59
|
-
return result
|
|
74
|
+
return await delivery()
|
|
60
75
|
except CoreDeliveryError as exc:
|
|
61
76
|
if not exc.retryable or attempt >= max_attempts:
|
|
62
77
|
raise
|
|
63
|
-
|
|
78
|
+
jitter_multiplier = 1 - jitter_ratio + (2 * jitter_ratio * random_value())
|
|
79
|
+
await asyncio.sleep(delay_seconds * jitter_multiplier)
|
|
64
80
|
attempt += 1
|
|
65
81
|
delay_seconds *= 2
|
|
66
82
|
|
|
@@ -71,6 +87,7 @@ async def dispatch_telemetry_delivery(
|
|
|
71
87
|
auth_context: RuntimeAuthContext,
|
|
72
88
|
device_id: str,
|
|
73
89
|
metrics: dict[str, Any],
|
|
90
|
+
config_id: str | None = None,
|
|
74
91
|
container_id: str | None = None,
|
|
75
92
|
units: dict[str, Any] | None = None,
|
|
76
93
|
timestamp: str | None = None,
|
|
@@ -87,21 +104,31 @@ async def dispatch_telemetry_delivery(
|
|
|
87
104
|
)
|
|
88
105
|
return False
|
|
89
106
|
|
|
107
|
+
resolved_timestamp = timestamp or dt.datetime.now(dt.timezone.utc).isoformat()
|
|
108
|
+
|
|
90
109
|
try:
|
|
91
110
|
await _run_with_retryable_core_delivery_backoff(
|
|
92
111
|
lambda: telemetry_client.send_metrics(
|
|
93
112
|
device_id=device_id,
|
|
94
113
|
metrics=metrics,
|
|
95
114
|
auth_context=auth_context,
|
|
115
|
+
config_id=config_id,
|
|
96
116
|
container_id=resolved_container_id,
|
|
97
117
|
units=units,
|
|
98
|
-
timestamp=
|
|
118
|
+
timestamp=resolved_timestamp,
|
|
99
119
|
)
|
|
100
120
|
)
|
|
101
121
|
return True
|
|
102
122
|
except Exception as exc:
|
|
103
123
|
if on_error is not None:
|
|
104
|
-
on_error(
|
|
124
|
+
on_error(
|
|
125
|
+
exc,
|
|
126
|
+
{
|
|
127
|
+
"device_id": device_id,
|
|
128
|
+
"config_id": config_id,
|
|
129
|
+
"container_id": resolved_container_id,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
105
132
|
return False
|
|
106
133
|
raise
|
|
107
134
|
|
|
@@ -113,12 +140,13 @@ def schedule_telemetry_delivery(
|
|
|
113
140
|
auth_context: RuntimeAuthContext,
|
|
114
141
|
device_id: str,
|
|
115
142
|
metrics: dict[str, Any],
|
|
143
|
+
config_id: str | None = None,
|
|
116
144
|
container_id: str | None = None,
|
|
117
145
|
units: dict[str, Any] | None = None,
|
|
118
146
|
timestamp: str | None = None,
|
|
119
147
|
on_skipped: SkipHandler | None = None,
|
|
120
148
|
on_error: ErrorHandler | None = None,
|
|
121
|
-
):
|
|
149
|
+
) -> asyncio.Task[bool]:
|
|
122
150
|
"""Schedule background telemetry delivery on the tracked runtime task set."""
|
|
123
151
|
return create_tracked_task(
|
|
124
152
|
dispatch_telemetry_delivery(
|
|
@@ -126,6 +154,7 @@ def schedule_telemetry_delivery(
|
|
|
126
154
|
auth_context=auth_context,
|
|
127
155
|
device_id=device_id,
|
|
128
156
|
metrics=metrics,
|
|
157
|
+
config_id=config_id,
|
|
129
158
|
container_id=container_id,
|
|
130
159
|
units=units,
|
|
131
160
|
timestamp=timestamp,
|
|
@@ -145,10 +174,16 @@ async def dispatch_event_delivery(
|
|
|
145
174
|
payload: Mapping[str, Any] | None = None,
|
|
146
175
|
source: str,
|
|
147
176
|
severity: str = "info",
|
|
177
|
+
event_id: str | None = None,
|
|
178
|
+
ts: str | dt.datetime | None = None,
|
|
179
|
+
topic: str | None = None,
|
|
148
180
|
on_skipped: SkipHandler | None = None,
|
|
149
181
|
on_error: ErrorHandler | None = None,
|
|
150
182
|
) -> bool:
|
|
151
183
|
"""Send one event to Core and return whether delivery succeeded."""
|
|
184
|
+
resolved_event_id = event_id or uuid4().hex
|
|
185
|
+
resolved_ts = ts or dt.datetime.now(dt.timezone.utc)
|
|
186
|
+
|
|
152
187
|
try:
|
|
153
188
|
event_sent = await _run_with_retryable_core_delivery_backoff(
|
|
154
189
|
lambda: event_client.send_event(
|
|
@@ -161,6 +196,9 @@ async def dispatch_event_delivery(
|
|
|
161
196
|
payload=payload,
|
|
162
197
|
source=source,
|
|
163
198
|
severity=severity,
|
|
199
|
+
event_id=resolved_event_id,
|
|
200
|
+
ts=resolved_ts,
|
|
201
|
+
topic=topic,
|
|
164
202
|
)
|
|
165
203
|
)
|
|
166
204
|
if not event_sent and on_skipped is not None:
|
|
@@ -199,11 +237,17 @@ def schedule_event_delivery(
|
|
|
199
237
|
payload: Mapping[str, Any] | None = None,
|
|
200
238
|
source: str,
|
|
201
239
|
severity: str = "info",
|
|
240
|
+
event_id: str | None = None,
|
|
241
|
+
ts: str | dt.datetime | None = None,
|
|
242
|
+
topic: str | None = None,
|
|
202
243
|
record_event: EventRecorder | None = None,
|
|
203
244
|
on_skipped: SkipHandler | None = None,
|
|
204
245
|
on_error: ErrorHandler | None = None,
|
|
205
|
-
):
|
|
246
|
+
) -> asyncio.Task[bool]:
|
|
206
247
|
"""Record an optional local event and schedule delivery to Core."""
|
|
248
|
+
resolved_event_id = event_id or uuid4().hex
|
|
249
|
+
resolved_ts = ts or dt.datetime.now(dt.timezone.utc)
|
|
250
|
+
|
|
207
251
|
if record_event is not None:
|
|
208
252
|
record_event(
|
|
209
253
|
build_local_event_record(
|
|
@@ -212,6 +256,9 @@ def schedule_event_delivery(
|
|
|
212
256
|
payload=payload,
|
|
213
257
|
source=source,
|
|
214
258
|
severity=severity,
|
|
259
|
+
event_id=resolved_event_id,
|
|
260
|
+
ts=resolved_ts,
|
|
261
|
+
topic=topic,
|
|
215
262
|
)
|
|
216
263
|
)
|
|
217
264
|
return create_tracked_task(
|
|
@@ -223,6 +270,9 @@ def schedule_event_delivery(
|
|
|
223
270
|
payload=payload,
|
|
224
271
|
source=source,
|
|
225
272
|
severity=severity,
|
|
273
|
+
event_id=resolved_event_id,
|
|
274
|
+
ts=resolved_ts,
|
|
275
|
+
topic=topic,
|
|
226
276
|
on_skipped=on_skipped,
|
|
227
277
|
on_error=on_error,
|
|
228
278
|
),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
"""Typed delivery errors for PiPhi runtime-to-Core communication."""
|
|
4
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
@@ -66,7 +66,7 @@ def classify_core_delivery_error(
|
|
|
66
66
|
timeout_seconds: float | None,
|
|
67
67
|
) -> CoreDeliveryError:
|
|
68
68
|
"""Convert raw HTTP client exceptions into stable PiPhi delivery errors."""
|
|
69
|
-
if isinstance(exc, httpx.
|
|
69
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
70
70
|
return CoreTimeoutError(
|
|
71
71
|
operation=operation,
|
|
72
72
|
url=url,
|
|
@@ -75,7 +75,7 @@ def classify_core_delivery_error(
|
|
|
75
75
|
timeout_seconds=timeout_seconds,
|
|
76
76
|
)
|
|
77
77
|
|
|
78
|
-
if isinstance(exc, httpx.
|
|
78
|
+
if isinstance(exc, httpx.TransportError):
|
|
79
79
|
return CoreUnavailableError(
|
|
80
80
|
operation=operation,
|
|
81
81
|
url=url,
|