vantage-litellm-callback 0.0.3__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.
- vantage_callback.py +3 -0
- vantage_litellm_callback/__init__.py +11 -0
- vantage_litellm_callback/callback.py +361 -0
- vantage_litellm_callback/client.py +185 -0
- vantage_litellm_callback/delivery.py +357 -0
- vantage_litellm_callback/projection.py +598 -0
- vantage_litellm_callback/provider_metadata.py +173 -0
- vantage_litellm_callback/schema.py +104 -0
- vantage_litellm_callback-0.0.3.dist-info/METADATA +100 -0
- vantage_litellm_callback-0.0.3.dist-info/RECORD +12 -0
- vantage_litellm_callback-0.0.3.dist-info/WHEEL +5 -0
- vantage_litellm_callback-0.0.3.dist-info/top_level.txt +2 -0
vantage_callback.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .callback import VantageLiteLLMCallback
|
|
2
|
+
from .delivery import DeliveryGapTracker, DeliveryWorker
|
|
3
|
+
|
|
4
|
+
callback_instance = VantageLiteLLMCallback()
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DeliveryGapTracker",
|
|
8
|
+
"DeliveryWorker",
|
|
9
|
+
"VantageLiteLLMCallback",
|
|
10
|
+
"callback_instance",
|
|
11
|
+
]
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from collections.abc import AsyncGenerator, AsyncIterable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from time import monotonic
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from litellm.integrations.custom_logger import CustomLogger
|
|
14
|
+
|
|
15
|
+
from .client import EventSink, UnixSocketClient
|
|
16
|
+
from .delivery import DeliveryGapTracker, DeliveryWorker
|
|
17
|
+
from .projection import (
|
|
18
|
+
is_terminal_stream_chunk,
|
|
19
|
+
merge_usage,
|
|
20
|
+
project_event,
|
|
21
|
+
resolve_event_timestamp,
|
|
22
|
+
usage_from_standard_logging,
|
|
23
|
+
usage_from_stream_chunk,
|
|
24
|
+
)
|
|
25
|
+
from .schema import Usage
|
|
26
|
+
|
|
27
|
+
ATTEMPT_TTL_SECONDS = 60 * 60
|
|
28
|
+
MAX_TRACKED_ATTEMPTS = 10_000
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class _Attempt:
|
|
35
|
+
event_id: str
|
|
36
|
+
created_at: float
|
|
37
|
+
request_start_time: datetime
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class VantageLiteLLMCallback(CustomLogger):
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
event_sink: EventSink | None = None,
|
|
45
|
+
queue_size: int | None = None,
|
|
46
|
+
recovery_probe_seconds: float | None = None,
|
|
47
|
+
gap_tracker: DeliveryGapTracker | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__(turn_off_message_logging=True)
|
|
50
|
+
self._delivery = DeliveryWorker(
|
|
51
|
+
event_sink or UnixSocketClient(),
|
|
52
|
+
queue_size=queue_size,
|
|
53
|
+
recovery_probe_seconds=recovery_probe_seconds,
|
|
54
|
+
gap_tracker=gap_tracker,
|
|
55
|
+
)
|
|
56
|
+
self._attempts: OrderedDict[str, _Attempt] = OrderedDict()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def gap_tracker(self) -> DeliveryGapTracker:
|
|
60
|
+
return self._delivery.gaps
|
|
61
|
+
|
|
62
|
+
async def wait_for_delivery(self) -> None:
|
|
63
|
+
await self._delivery.wait_for_idle()
|
|
64
|
+
|
|
65
|
+
async def close(self) -> None:
|
|
66
|
+
await self._delivery.close()
|
|
67
|
+
|
|
68
|
+
async def async_pre_call_deployment_hook(
|
|
69
|
+
self, kwargs: dict[str, Any], call_type: Any
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
del call_type
|
|
72
|
+
event_id = f"lta_{uuid4().hex}"
|
|
73
|
+
try:
|
|
74
|
+
request_start_time = resolve_event_timestamp(kwargs)
|
|
75
|
+
call_id = _litellm_call_id(kwargs)
|
|
76
|
+
if call_id is not None:
|
|
77
|
+
self._remember_attempt(call_id, event_id, request_start_time)
|
|
78
|
+
event = project_event(
|
|
79
|
+
kwargs,
|
|
80
|
+
event_id=event_id,
|
|
81
|
+
status="started",
|
|
82
|
+
request_start_time=request_start_time,
|
|
83
|
+
)
|
|
84
|
+
self._delivery.enqueue(event)
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
raise
|
|
87
|
+
except Exception: # noqa: BLE001 - callback hooks must fail open
|
|
88
|
+
self._record_gap("projection", event_id=event_id, status="started")
|
|
89
|
+
return kwargs
|
|
90
|
+
|
|
91
|
+
async def async_post_call_success_deployment_hook(
|
|
92
|
+
self,
|
|
93
|
+
request_data: dict[str, Any],
|
|
94
|
+
response: Any,
|
|
95
|
+
call_type: Any,
|
|
96
|
+
) -> Any:
|
|
97
|
+
del call_type
|
|
98
|
+
attempt: _Attempt | None = None
|
|
99
|
+
try:
|
|
100
|
+
attempt = self._take_attempt(request_data)
|
|
101
|
+
if attempt is None:
|
|
102
|
+
return response
|
|
103
|
+
event = project_event(
|
|
104
|
+
request_data,
|
|
105
|
+
event_id=attempt.event_id,
|
|
106
|
+
status="success",
|
|
107
|
+
response=response,
|
|
108
|
+
request_start_time=attempt.request_start_time,
|
|
109
|
+
)
|
|
110
|
+
self._delivery.enqueue(event)
|
|
111
|
+
except asyncio.CancelledError:
|
|
112
|
+
raise
|
|
113
|
+
except Exception: # noqa: BLE001 - callback hooks must fail open
|
|
114
|
+
event_id = attempt.event_id if attempt is not None else None
|
|
115
|
+
self._record_gap("projection", event_id=event_id, status="success")
|
|
116
|
+
return response
|
|
117
|
+
|
|
118
|
+
async def async_log_success_event(
|
|
119
|
+
self,
|
|
120
|
+
kwargs: dict[str, Any],
|
|
121
|
+
response_obj: Any,
|
|
122
|
+
start_time: Any,
|
|
123
|
+
end_time: Any,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Close attempts the deployment and iterator hooks could not.
|
|
126
|
+
|
|
127
|
+
LiteLLM builds ``standard_logging_object`` only after those hooks
|
|
128
|
+
fire, and hands it exclusively to this hook (``kwargs`` is the logging
|
|
129
|
+
object's ``model_call_details``). The LiteLLM call ID finds the
|
|
130
|
+
in-memory attempt. Removing that attempt keeps this a no-op whenever
|
|
131
|
+
a primary hook already emitted.
|
|
132
|
+
"""
|
|
133
|
+
del start_time, end_time
|
|
134
|
+
attempt: _Attempt | None = None
|
|
135
|
+
try:
|
|
136
|
+
attempt = self._take_attempt(kwargs)
|
|
137
|
+
if attempt is None:
|
|
138
|
+
return
|
|
139
|
+
standard_logging_object = kwargs.get("standard_logging_object")
|
|
140
|
+
event = project_event(
|
|
141
|
+
kwargs,
|
|
142
|
+
event_id=attempt.event_id,
|
|
143
|
+
status="success",
|
|
144
|
+
response=response_obj,
|
|
145
|
+
usage_override=usage_from_standard_logging(standard_logging_object),
|
|
146
|
+
standard_logging_object=standard_logging_object,
|
|
147
|
+
request_start_time=attempt.request_start_time,
|
|
148
|
+
)
|
|
149
|
+
self._delivery.enqueue(event)
|
|
150
|
+
except asyncio.CancelledError:
|
|
151
|
+
raise
|
|
152
|
+
except Exception: # noqa: BLE001 - callback hooks must fail open
|
|
153
|
+
event_id = attempt.event_id if attempt is not None else None
|
|
154
|
+
self._record_gap("projection", event_id=event_id, status="success")
|
|
155
|
+
|
|
156
|
+
async def async_post_call_failure_hook(
|
|
157
|
+
self,
|
|
158
|
+
request_data: dict[str, Any],
|
|
159
|
+
original_exception: Exception,
|
|
160
|
+
user_api_key_dict: Any,
|
|
161
|
+
traceback_str: str | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
del original_exception, user_api_key_dict, traceback_str
|
|
164
|
+
attempt: _Attempt | None = None
|
|
165
|
+
try:
|
|
166
|
+
attempt = self._take_attempt(request_data)
|
|
167
|
+
if attempt is None:
|
|
168
|
+
return
|
|
169
|
+
event = project_event(
|
|
170
|
+
request_data,
|
|
171
|
+
event_id=attempt.event_id,
|
|
172
|
+
status="failure",
|
|
173
|
+
request_start_time=attempt.request_start_time,
|
|
174
|
+
)
|
|
175
|
+
self._delivery.enqueue(event)
|
|
176
|
+
except asyncio.CancelledError:
|
|
177
|
+
raise
|
|
178
|
+
except Exception: # noqa: BLE001 - callback hooks must fail open
|
|
179
|
+
event_id = attempt.event_id if attempt is not None else None
|
|
180
|
+
self._record_gap("projection", event_id=event_id, status="failure")
|
|
181
|
+
|
|
182
|
+
async def async_post_call_streaming_iterator_hook(
|
|
183
|
+
self,
|
|
184
|
+
user_api_key_dict: Any,
|
|
185
|
+
response: AsyncIterable[object],
|
|
186
|
+
request_data: dict[str, Any],
|
|
187
|
+
) -> AsyncGenerator[object, None]:
|
|
188
|
+
del user_api_key_dict
|
|
189
|
+
try:
|
|
190
|
+
attempt = self._find_attempt(request_data)
|
|
191
|
+
except asyncio.CancelledError:
|
|
192
|
+
raise
|
|
193
|
+
except Exception: # noqa: BLE001 - callback hooks must fail open
|
|
194
|
+
attempt = None
|
|
195
|
+
if attempt is None:
|
|
196
|
+
async for chunk in response:
|
|
197
|
+
yield chunk
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
terminal_seen = False
|
|
201
|
+
usage = Usage()
|
|
202
|
+
try:
|
|
203
|
+
async for chunk in response:
|
|
204
|
+
try:
|
|
205
|
+
usage = merge_usage(usage, usage_from_stream_chunk(chunk))
|
|
206
|
+
if not terminal_seen and is_terminal_stream_chunk(chunk):
|
|
207
|
+
terminal_seen = True
|
|
208
|
+
status = _stream_terminal_status(chunk)
|
|
209
|
+
terminal_attempt = self._take_attempt(
|
|
210
|
+
request_data, expected=attempt
|
|
211
|
+
)
|
|
212
|
+
if terminal_attempt is None:
|
|
213
|
+
yield chunk
|
|
214
|
+
continue
|
|
215
|
+
event = project_event(
|
|
216
|
+
request_data,
|
|
217
|
+
event_id=terminal_attempt.event_id,
|
|
218
|
+
status=status,
|
|
219
|
+
response=chunk,
|
|
220
|
+
usage_override=usage,
|
|
221
|
+
request_start_time=terminal_attempt.request_start_time,
|
|
222
|
+
)
|
|
223
|
+
self._delivery.enqueue(event)
|
|
224
|
+
except asyncio.CancelledError:
|
|
225
|
+
raise
|
|
226
|
+
except Exception: # noqa: BLE001 - callback hooks must fail open
|
|
227
|
+
self._record_gap(
|
|
228
|
+
"projection",
|
|
229
|
+
event_id=attempt.event_id,
|
|
230
|
+
status="success",
|
|
231
|
+
)
|
|
232
|
+
yield chunk
|
|
233
|
+
finally:
|
|
234
|
+
if not terminal_seen:
|
|
235
|
+
self._close_unterminated_stream(request_data, attempt, usage)
|
|
236
|
+
|
|
237
|
+
def _close_unterminated_stream(
|
|
238
|
+
self,
|
|
239
|
+
request_data: Mapping[str, Any],
|
|
240
|
+
attempt: _Attempt,
|
|
241
|
+
usage: Usage,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Close a stream that ended without a terminal chunk.
|
|
244
|
+
|
|
245
|
+
With no usage accumulated the attempt stays in the map so
|
|
246
|
+
``async_log_success_event`` can close it with LiteLLM's assembled
|
|
247
|
+
usage, or the failure hook can close an error. With usage in hand
|
|
248
|
+
this emits success itself.
|
|
249
|
+
"""
|
|
250
|
+
if usage == Usage():
|
|
251
|
+
logger.warning(
|
|
252
|
+
"Vantage callback: stream ended without a terminal marker or "
|
|
253
|
+
"usage; deferring to the success-event safety net"
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
logger.warning(
|
|
258
|
+
"Vantage callback: stream ended without a terminal marker; "
|
|
259
|
+
"emitting success with usage merged from chunks"
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
terminal_attempt = self._take_attempt(request_data, expected=attempt)
|
|
263
|
+
if terminal_attempt is None:
|
|
264
|
+
return
|
|
265
|
+
event = project_event(
|
|
266
|
+
request_data,
|
|
267
|
+
event_id=terminal_attempt.event_id,
|
|
268
|
+
status="success",
|
|
269
|
+
usage_override=usage,
|
|
270
|
+
request_start_time=terminal_attempt.request_start_time,
|
|
271
|
+
)
|
|
272
|
+
self._delivery.enqueue(event)
|
|
273
|
+
except asyncio.CancelledError:
|
|
274
|
+
raise
|
|
275
|
+
except Exception: # noqa: BLE001 - callback hooks must fail open
|
|
276
|
+
self._record_gap(
|
|
277
|
+
"projection",
|
|
278
|
+
event_id=attempt.event_id,
|
|
279
|
+
status="success",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _record_gap(
|
|
283
|
+
self,
|
|
284
|
+
category: str,
|
|
285
|
+
*,
|
|
286
|
+
event_id: str | None,
|
|
287
|
+
status: str,
|
|
288
|
+
) -> None:
|
|
289
|
+
try:
|
|
290
|
+
self._delivery.record_gap(
|
|
291
|
+
category, # type: ignore[arg-type]
|
|
292
|
+
event_id=event_id,
|
|
293
|
+
status=status, # type: ignore[arg-type]
|
|
294
|
+
)
|
|
295
|
+
except asyncio.CancelledError:
|
|
296
|
+
raise
|
|
297
|
+
except Exception: # noqa: BLE001 - gap tracking must fail open
|
|
298
|
+
# Gap accounting itself must not affect provider traffic.
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
def _remember_attempt(
|
|
302
|
+
self, call_id: str, event_id: str, request_start_time: datetime
|
|
303
|
+
) -> None:
|
|
304
|
+
now = monotonic()
|
|
305
|
+
self._evict_attempts(now)
|
|
306
|
+
self._attempts.pop(call_id, None)
|
|
307
|
+
while len(self._attempts) >= MAX_TRACKED_ATTEMPTS:
|
|
308
|
+
self._attempts.popitem(last=False)
|
|
309
|
+
self._attempts[call_id] = _Attempt(
|
|
310
|
+
event_id=event_id,
|
|
311
|
+
created_at=now,
|
|
312
|
+
request_start_time=request_start_time,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def _find_attempt(self, request_data: Mapping[str, Any]) -> _Attempt | None:
|
|
316
|
+
self._evict_attempts(monotonic())
|
|
317
|
+
call_id = _litellm_call_id(request_data)
|
|
318
|
+
if call_id is None:
|
|
319
|
+
return None
|
|
320
|
+
return self._attempts.get(call_id)
|
|
321
|
+
|
|
322
|
+
def _take_attempt(
|
|
323
|
+
self,
|
|
324
|
+
request_data: Mapping[str, Any],
|
|
325
|
+
*,
|
|
326
|
+
expected: _Attempt | None = None,
|
|
327
|
+
) -> _Attempt | None:
|
|
328
|
+
attempt = self._find_attempt(request_data)
|
|
329
|
+
if attempt is None or (expected is not None and attempt is not expected):
|
|
330
|
+
return None
|
|
331
|
+
call_id = _litellm_call_id(request_data)
|
|
332
|
+
if call_id is None:
|
|
333
|
+
return None
|
|
334
|
+
return self._attempts.pop(call_id, None)
|
|
335
|
+
|
|
336
|
+
def _evict_attempts(self, now: float) -> None:
|
|
337
|
+
while self._attempts:
|
|
338
|
+
_, attempt = next(iter(self._attempts.items()))
|
|
339
|
+
if now - attempt.created_at < ATTEMPT_TTL_SECONDS:
|
|
340
|
+
break
|
|
341
|
+
self._attempts.popitem(last=False)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _litellm_call_id(request_data: Mapping[str, Any]) -> str | None:
|
|
345
|
+
call_id = request_data.get("litellm_call_id")
|
|
346
|
+
if isinstance(call_id, str) and call_id:
|
|
347
|
+
return call_id
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _stream_terminal_status(response: object) -> str:
|
|
352
|
+
if isinstance(response, Mapping):
|
|
353
|
+
status = response.get("status")
|
|
354
|
+
event_type = response.get("type")
|
|
355
|
+
if (
|
|
356
|
+
status in {"failed", "cancelled", "incomplete"}
|
|
357
|
+
or event_type in {"response.failed", "response.incomplete"}
|
|
358
|
+
or response.get("error") is not None
|
|
359
|
+
):
|
|
360
|
+
return "failure"
|
|
361
|
+
return "success"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from .schema import DeliveryGap, DeliveryGapAck, Frame, GapCategory, UsageAck
|
|
11
|
+
|
|
12
|
+
DEFAULT_SOCKET_PATH = "/var/run/vantage-collector/collector.sock"
|
|
13
|
+
DEFAULT_ACK_TIMEOUT_SECONDS = 5.0
|
|
14
|
+
DEFAULT_CONNECTION_POOL_SIZE = 16
|
|
15
|
+
MAX_ACK_BYTES = 4096
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeliveryError(RuntimeError):
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
category: GapCategory = "unknown",
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.category = category
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EventSink(Protocol):
|
|
30
|
+
async def send(self, frame: Frame) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class _Connection:
|
|
35
|
+
reader: asyncio.StreamReader | None = None
|
|
36
|
+
writer: asyncio.StreamWriter | None = None
|
|
37
|
+
|
|
38
|
+
async def close(self) -> None:
|
|
39
|
+
writer = self.writer
|
|
40
|
+
self.reader = None
|
|
41
|
+
self.writer = None
|
|
42
|
+
if writer is None:
|
|
43
|
+
return
|
|
44
|
+
writer.close()
|
|
45
|
+
await writer.wait_closed()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnixSocketClient:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
socket_path: str | None = None,
|
|
52
|
+
ack_timeout_seconds: float | None = None,
|
|
53
|
+
connection_pool_size: int | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.socket_path = socket_path or os.getenv(
|
|
56
|
+
"VANTAGE_COLLECTOR_SOCKET_PATH", DEFAULT_SOCKET_PATH
|
|
57
|
+
)
|
|
58
|
+
self.ack_timeout_seconds = ack_timeout_seconds or _positive_float_from_env(
|
|
59
|
+
"VANTAGE_COLLECTOR_ACK_TIMEOUT_SECONDS",
|
|
60
|
+
DEFAULT_ACK_TIMEOUT_SECONDS,
|
|
61
|
+
)
|
|
62
|
+
pool_size = connection_pool_size or _positive_int_from_env(
|
|
63
|
+
"VANTAGE_COLLECTOR_CONNECTION_POOL_SIZE",
|
|
64
|
+
DEFAULT_CONNECTION_POOL_SIZE,
|
|
65
|
+
)
|
|
66
|
+
self._connections = asyncio.LifoQueue[_Connection](maxsize=pool_size)
|
|
67
|
+
for connection in tuple(_Connection() for _ in range(pool_size)):
|
|
68
|
+
self._connections.put_nowait(connection)
|
|
69
|
+
|
|
70
|
+
async def send(self, frame: Frame) -> None:
|
|
71
|
+
connection = await self._connections.get()
|
|
72
|
+
try:
|
|
73
|
+
await self._send_on_connection(connection, frame)
|
|
74
|
+
except asyncio.CancelledError:
|
|
75
|
+
await connection.close()
|
|
76
|
+
raise
|
|
77
|
+
except asyncio.TimeoutError as error:
|
|
78
|
+
await connection.close()
|
|
79
|
+
raise DeliveryError(
|
|
80
|
+
f"Vantage collector acknowledgement timed out for {_frame_id(frame)}",
|
|
81
|
+
category="timeout",
|
|
82
|
+
) from error
|
|
83
|
+
except OSError as error:
|
|
84
|
+
await connection.close()
|
|
85
|
+
raise DeliveryError(
|
|
86
|
+
f"Vantage collector socket unavailable for {_frame_id(frame)}",
|
|
87
|
+
category="socket_unavailable",
|
|
88
|
+
) from error
|
|
89
|
+
except CollectorRejection as error:
|
|
90
|
+
await connection.close()
|
|
91
|
+
raise DeliveryError(
|
|
92
|
+
f"Vantage collector rejected {_frame_id(frame)}",
|
|
93
|
+
category="collector_rejection",
|
|
94
|
+
) from error
|
|
95
|
+
except (ValidationError, UnicodeDecodeError, ValueError) as error:
|
|
96
|
+
await connection.close()
|
|
97
|
+
raise DeliveryError(
|
|
98
|
+
f"Invalid Vantage collector acknowledgement for {_frame_id(frame)}",
|
|
99
|
+
category="protocol_ack",
|
|
100
|
+
) from error
|
|
101
|
+
finally:
|
|
102
|
+
self._connections.put_nowait(connection)
|
|
103
|
+
|
|
104
|
+
async def close(self) -> None:
|
|
105
|
+
connections = tuple(
|
|
106
|
+
await asyncio.gather(
|
|
107
|
+
*(self._connections.get() for _ in range(self._connections.maxsize))
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
await asyncio.gather(*(connection.close() for connection in connections))
|
|
111
|
+
for connection in connections:
|
|
112
|
+
self._connections.put_nowait(connection)
|
|
113
|
+
|
|
114
|
+
async def _send_on_connection(self, connection: _Connection, frame: Frame) -> None:
|
|
115
|
+
if (
|
|
116
|
+
connection.reader is None
|
|
117
|
+
or connection.writer is None
|
|
118
|
+
or connection.writer.is_closing()
|
|
119
|
+
):
|
|
120
|
+
connection.reader, connection.writer = await asyncio.wait_for(
|
|
121
|
+
asyncio.open_unix_connection(self.socket_path),
|
|
122
|
+
timeout=self.ack_timeout_seconds,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
payload = (
|
|
126
|
+
frame.model_dump_json(exclude_none=True, by_alias=True).encode("utf-8")
|
|
127
|
+
+ b"\n"
|
|
128
|
+
)
|
|
129
|
+
connection.writer.write(payload)
|
|
130
|
+
await asyncio.wait_for(
|
|
131
|
+
connection.writer.drain(), timeout=self.ack_timeout_seconds
|
|
132
|
+
)
|
|
133
|
+
ack_line = await asyncio.wait_for(
|
|
134
|
+
connection.reader.readline(),
|
|
135
|
+
timeout=self.ack_timeout_seconds,
|
|
136
|
+
)
|
|
137
|
+
if (
|
|
138
|
+
not ack_line
|
|
139
|
+
or len(ack_line) > MAX_ACK_BYTES
|
|
140
|
+
or not ack_line.endswith(b"\n")
|
|
141
|
+
):
|
|
142
|
+
raise ValueError("invalid collector acknowledgement frame")
|
|
143
|
+
|
|
144
|
+
if isinstance(frame, DeliveryGap):
|
|
145
|
+
ack = DeliveryGapAck.model_validate_json(ack_line)
|
|
146
|
+
expected_id = frame.gap_id
|
|
147
|
+
acknowledged_id = ack.gap_id
|
|
148
|
+
else:
|
|
149
|
+
ack = UsageAck.model_validate_json(ack_line)
|
|
150
|
+
expected_id = frame.event_id
|
|
151
|
+
acknowledged_id = ack.event_id
|
|
152
|
+
if ack.error:
|
|
153
|
+
raise CollectorRejection(ack.error)
|
|
154
|
+
if acknowledged_id != expected_id:
|
|
155
|
+
raise ValueError("collector acknowledgement ID mismatch")
|
|
156
|
+
if ack.collector_sequence is None:
|
|
157
|
+
raise ValueError("collector acknowledgement omitted durable sequence")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class CollectorRejection(ValueError):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _frame_id(frame: Frame) -> str:
|
|
165
|
+
return frame.gap_id if isinstance(frame, DeliveryGap) else frame.event_id
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _positive_float_from_env(name: str, default: float) -> float:
|
|
169
|
+
raw_value = os.getenv(name)
|
|
170
|
+
if raw_value is None:
|
|
171
|
+
return default
|
|
172
|
+
value = float(raw_value)
|
|
173
|
+
if value <= 0:
|
|
174
|
+
raise ValueError(f"{name} must be greater than zero")
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _positive_int_from_env(name: str, default: int) -> int:
|
|
179
|
+
raw_value = os.getenv(name)
|
|
180
|
+
if raw_value is None:
|
|
181
|
+
return default
|
|
182
|
+
value = int(raw_value)
|
|
183
|
+
if value <= 0:
|
|
184
|
+
raise ValueError(f"{name} must be greater than zero")
|
|
185
|
+
return value
|