sediment-cli 0.1.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.
@@ -0,0 +1,1334 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Bounded, opt-in transport of immutable prepared HTTP bodies (ADR 0017).
3
+
4
+ This module is Python 3.12 stdlib-only so a standalone copy is also a supported
5
+ gateway/fleet implementation. Transport receipts never establish OTLP Fact counts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import base64
12
+ import binascii
13
+ import fcntl
14
+ import hashlib
15
+ import http.client
16
+ import ipaddress
17
+ import json
18
+ import logging
19
+ import math
20
+ import os
21
+ import re
22
+ import secrets
23
+ import signal
24
+ import socket
25
+ import ssl
26
+ import stat
27
+ import sys
28
+ import threading
29
+ import time
30
+ import urllib.parse
31
+ import uuid
32
+ from collections import Counter
33
+ from contextlib import contextmanager
34
+ from dataclasses import dataclass, replace
35
+ from datetime import UTC, datetime
36
+ from pathlib import Path
37
+ from typing import Mapping
38
+
39
+ FORMAT_VERSION = 1
40
+ MAX_ACTIVE_BYTES = 256 * 1024 * 1024
41
+ MAX_ACTIVE_ENTRIES = 2048
42
+ MAX_ENTRY_BYTES = 8 * 1024 * 1024
43
+ MAX_STDIN_BYTES = ((MAX_ENTRY_BYTES + 2) // 3) * 4 + 16384
44
+ REPLAY_WINDOW_SECONDS = 24 * 60 * 60
45
+ RECEIPT_RETENTION_SECONDS = 7 * 24 * 60 * 60
46
+ MAX_RECEIPTS = 2048
47
+ MAX_BATCH_ATTEMPTS = 32
48
+ HTTP_TIMEOUT_SECONDS = 5
49
+ MAX_BACKOFF_SECONDS = 60
50
+ MAX_RESPONSE_BYTES = 65536
51
+ _MAX_HEADER_BYTES = 16384
52
+ _TERMINAL = {"acknowledged", "skipped", "declined"}
53
+ _REASONS = {
54
+ "buffered",
55
+ "already_buffered",
56
+ "gateway_stored",
57
+ "gateway_duplicate",
58
+ "otlp_delivered",
59
+ "no_session",
60
+ "invalid_acknowledgment",
61
+ "transport_failure",
62
+ "retryable_http",
63
+ "authentication_rejected",
64
+ "client_rejected",
65
+ "redirect_rejected",
66
+ "configuration_missing",
67
+ "configuration_invalid",
68
+ "destination_changed",
69
+ "credentials_missing",
70
+ "credentials_invalid",
71
+ "buffer_full",
72
+ "entry_too_large",
73
+ "unsafe_storage",
74
+ "storage_unavailable",
75
+ "storage_busy",
76
+ "worker_busy",
77
+ "expired",
78
+ "identity_conflict",
79
+ "invalid_record",
80
+ "invalid_request",
81
+ "buffer_disabled",
82
+ }
83
+ logger = logging.getLogger("sediment.delivery")
84
+ # A timed-out OS resolver may finish later; only one resolver can remain alive.
85
+ _DNS_LOCK = threading.Lock()
86
+
87
+
88
+ class DeliveryError(ValueError):
89
+ """A closed, content-free transport failure."""
90
+
91
+ def __init__(self, reason: str):
92
+ super().__init__(reason)
93
+ self.reason = reason
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Disposition:
98
+ delivery_id: str
99
+ status: str
100
+ reason: str
101
+ fact_id: str | None = None
102
+ stored: bool | None = None
103
+ fallback_reason: str | None = None
104
+
105
+ def as_dict(self) -> dict:
106
+ result = {
107
+ "format_version": FORMAT_VERSION,
108
+ "delivery_id": self.delivery_id,
109
+ "status": self.status,
110
+ "reason": self.reason,
111
+ }
112
+ if self.fact_id is not None:
113
+ result.update(fact_id=self.fact_id, stored=self.stored)
114
+ if self.fallback_reason is not None:
115
+ result["fallback_reason"] = self.fallback_reason
116
+ return result
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class DeliveryRequest:
121
+ channel: str
122
+ destination: str
123
+ body: bytes
124
+ captured_at: str
125
+ delivery_id: str
126
+
127
+ def as_dict(self) -> dict:
128
+ return {
129
+ "channel": self.channel,
130
+ "destination": self.destination,
131
+ "body_base64": base64.b64encode(self.body).decode("ascii"),
132
+ "captured_at": self.captured_at,
133
+ "delivery_id": self.delivery_id,
134
+ }
135
+
136
+
137
+ def _uuid(value: str) -> str:
138
+ if not isinstance(value, str):
139
+ raise DeliveryError("invalid_request")
140
+ try:
141
+ return str(uuid.UUID(value))
142
+ except ValueError:
143
+ raise DeliveryError("invalid_request") from None
144
+
145
+
146
+ def _instant(value: str) -> str:
147
+ if not isinstance(value, str):
148
+ raise DeliveryError("invalid_request")
149
+ try:
150
+ instant = datetime.fromisoformat(value)
151
+ if instant.tzinfo is None or instant.utcoffset() is None:
152
+ raise ValueError
153
+ return instant.astimezone(UTC).isoformat()
154
+ except (ValueError, OverflowError):
155
+ raise DeliveryError("invalid_request") from None
156
+
157
+
158
+ def _destination(channel: str, value: str) -> str:
159
+ if (
160
+ not isinstance(channel, str)
161
+ or channel not in {"gateway", "otlp"}
162
+ or not isinstance(value, str)
163
+ ):
164
+ raise DeliveryError("invalid_request")
165
+ if len(value) > 2048:
166
+ raise DeliveryError("invalid_request")
167
+ value = value.strip().rstrip("/")
168
+ if not value or any(ord(c) < 33 or ord(c) == 127 for c in value):
169
+ raise DeliveryError("invalid_request")
170
+ try:
171
+ parsed = urllib.parse.urlsplit(value)
172
+ host, port = parsed.hostname, parsed.port
173
+ if (
174
+ parsed.scheme not in {"http", "https"}
175
+ or not host
176
+ or parsed.username is not None
177
+ or parsed.password is not None
178
+ or "?" in value
179
+ or "#" in value
180
+ or parsed.netloc.endswith(":")
181
+ or "\\" in value
182
+ or port == 0
183
+ ):
184
+ raise ValueError
185
+ path = "/ingest/gateway" if channel == "gateway" else "/v1/logs"
186
+ if parsed.path not in {"", "/", path}:
187
+ raise ValueError
188
+ if channel == "otlp" and parsed.scheme == "http" and host != "localhost":
189
+ if not ipaddress.ip_address(host).is_loopback:
190
+ raise ValueError
191
+ host.encode("ascii")
192
+ authority = f"[{host}]" if ":" in host else host
193
+ if port is not None:
194
+ authority += f":{port}"
195
+ return urllib.parse.urlunsplit((parsed.scheme, authority, path, "", ""))
196
+ except (ValueError, UnicodeError):
197
+ raise DeliveryError("invalid_request") from None
198
+
199
+
200
+ def prepare_request(
201
+ channel: str,
202
+ destination: str,
203
+ body: bytes,
204
+ *,
205
+ captured_at: str | None = None,
206
+ delivery_id: str | None = None,
207
+ ) -> DeliveryRequest:
208
+ if not isinstance(body, bytes):
209
+ raise DeliveryError("invalid_request")
210
+ if len(body) > MAX_ENTRY_BYTES:
211
+ raise DeliveryError("entry_too_large")
212
+ return DeliveryRequest(
213
+ channel=channel,
214
+ destination=_destination(channel, destination),
215
+ body=body,
216
+ captured_at=_instant(
217
+ captured_at if captured_at is not None else datetime.now(UTC).isoformat()
218
+ ),
219
+ delivery_id=_uuid(delivery_id)
220
+ if delivery_id is not None
221
+ else str(uuid.uuid4()),
222
+ )
223
+
224
+
225
+ def _validated(request: DeliveryRequest) -> DeliveryRequest:
226
+ if not isinstance(request, DeliveryRequest):
227
+ raise DeliveryError("invalid_request")
228
+ return prepare_request(
229
+ request.channel,
230
+ request.destination,
231
+ request.body,
232
+ captured_at=request.captured_at,
233
+ delivery_id=request.delivery_id,
234
+ )
235
+
236
+
237
+ def _report(disposition: Disposition) -> Disposition:
238
+ logger.warning(
239
+ "delivery_disposition delivery_id=%s status=%s reason=%s",
240
+ disposition.delivery_id,
241
+ disposition.status,
242
+ disposition.reason,
243
+ )
244
+ return disposition
245
+
246
+
247
+ def _json(data: bytes | str):
248
+ def object_pairs(pairs):
249
+ result = {}
250
+ for key, value in pairs:
251
+ if key in result:
252
+ raise ValueError
253
+ result[key] = value
254
+ return result
255
+
256
+ def constant(value):
257
+ raise ValueError
258
+
259
+ try:
260
+ return json.loads(data, object_pairs_hook=object_pairs, parse_constant=constant)
261
+ except RecursionError:
262
+ raise ValueError from None
263
+
264
+
265
+ def _encoded(value) -> bytes:
266
+ return json.dumps(
267
+ value, ensure_ascii=True, allow_nan=False, separators=(",", ":"), sort_keys=True
268
+ ).encode()
269
+
270
+
271
+ def configured_destination(channel: str, env: Mapping[str, str] | None = None) -> str:
272
+ env = os.environ if env is None else env
273
+ name = "SEDIMENT_INGEST_URL" if channel == "gateway" else "SEDIMENT_OTLP_ENDPOINT"
274
+ value = env.get(name)
275
+ if not value:
276
+ raise DeliveryError("configuration_missing")
277
+ try:
278
+ destination = _destination(channel, value)
279
+ parsed = urllib.parse.urlsplit(destination)
280
+ if channel == "gateway" and parsed.scheme == "http":
281
+ try:
282
+ loopback = ipaddress.ip_address(parsed.hostname).is_loopback
283
+ except ValueError:
284
+ loopback = parsed.hostname == "localhost"
285
+ if not loopback:
286
+ origin = env.get("SEDIMENT_GATEWAY_LOCAL_HTTP_ORIGIN", "")
287
+ # Only this active, exact origin authorizes plaintext gateway
288
+ # delivery. Prepared records never store or enable the exception.
289
+ if any(ord(c) < 33 or ord(c) == 127 for c in origin):
290
+ raise DeliveryError("configuration_invalid")
291
+ allowed = urllib.parse.urlsplit(_destination("gateway", origin))
292
+ raw = urllib.parse.urlsplit(origin)
293
+ if (
294
+ raw.path not in {"", "/"}
295
+ or allowed.scheme != "http"
296
+ or (parsed.scheme, parsed.hostname, parsed.port)
297
+ != (allowed.scheme, allowed.hostname, allowed.port)
298
+ ):
299
+ raise DeliveryError("configuration_invalid")
300
+ return destination
301
+ except DeliveryError:
302
+ raise DeliveryError("configuration_invalid") from None
303
+
304
+
305
+ def _credential(channel: str, env: Mapping[str, str]) -> str:
306
+ token = env.get(
307
+ "SEDIMENT_API_BEARER_TOKEN"
308
+ if channel == "gateway"
309
+ else "SEDIMENT_INGEST_TOKEN",
310
+ "",
311
+ )
312
+ if not token and channel == "otlp":
313
+ for field in env.get("OTEL_EXPORTER_OTLP_HEADERS", "").split(","):
314
+ key, separator, value = field.partition("=")
315
+ if separator and key.strip().lower() == "authorization":
316
+ value = urllib.parse.unquote(value).strip()
317
+ if value.lower().startswith("bearer "):
318
+ token = value[7:]
319
+ break
320
+ if not token:
321
+ raise DeliveryError("credentials_missing")
322
+ if not isinstance(token, str) or any(ord(c) < 32 or ord(c) > 126 for c in token):
323
+ raise DeliveryError("credentials_invalid")
324
+ return token
325
+
326
+
327
+ def _configuration(request: DeliveryRequest, env: Mapping[str, str]) -> str:
328
+ if configured_destination(request.channel, env) != request.destination:
329
+ raise DeliveryError("destination_changed")
330
+ return _credential(request.channel, env)
331
+
332
+
333
+ def _acknowledgment(request: DeliveryRequest, body: bytes) -> Disposition:
334
+ invalid = Disposition(request.delivery_id, "pending", "invalid_acknowledgment")
335
+ try:
336
+ result = _json(body)
337
+ except (ValueError, UnicodeError):
338
+ return invalid
339
+ if not isinstance(result, dict):
340
+ return invalid
341
+ if request.channel == "otlp":
342
+ return (
343
+ Disposition(request.delivery_id, "acknowledged", "otlp_delivered")
344
+ if result == {}
345
+ else invalid
346
+ )
347
+ if set(result) == {"fact_id", "stored"}:
348
+ fact_id = result["fact_id"]
349
+ if (
350
+ isinstance(fact_id, str)
351
+ and fact_id.strip() == fact_id
352
+ and fact_id
353
+ and len(fact_id) <= 1024
354
+ and not any(ord(c) < 32 for c in fact_id)
355
+ and type(result["stored"]) is bool
356
+ ):
357
+ return Disposition(
358
+ request.delivery_id,
359
+ "acknowledged",
360
+ "gateway_stored" if result["stored"] else "gateway_duplicate",
361
+ fact_id,
362
+ result["stored"],
363
+ )
364
+ if (
365
+ set(result) == {"skipped", "reason"}
366
+ and result["skipped"] is True
367
+ and result["reason"] == "no_session"
368
+ ):
369
+ return Disposition(request.delivery_id, "skipped", "no_session")
370
+ return invalid
371
+
372
+
373
+ def _remaining(deadline: float) -> float:
374
+ remaining = deadline - time.monotonic()
375
+ if remaining <= 0:
376
+ raise TimeoutError
377
+ return remaining
378
+
379
+
380
+ def _connect(host: str, port: int, *, secure: bool, deadline: float):
381
+ # socket's timeout excludes DNS. Bound the caller's wait and the number of
382
+ # outstanding OS lookups; a late resolver never receives or sends a payload.
383
+ if not _DNS_LOCK.acquire(timeout=_remaining(deadline)):
384
+ raise TimeoutError
385
+ done = threading.Event()
386
+ addresses, errors = [], []
387
+
388
+ def resolve():
389
+ try:
390
+ addresses.extend(socket.getaddrinfo(host, port, type=socket.SOCK_STREAM))
391
+ except OSError as exc:
392
+ errors.append(exc)
393
+ finally:
394
+ _DNS_LOCK.release()
395
+ done.set()
396
+
397
+ try:
398
+ threading.Thread(target=resolve, daemon=True).start()
399
+ except RuntimeError:
400
+ _DNS_LOCK.release()
401
+ raise OSError from None
402
+ if not done.wait(_remaining(deadline)):
403
+ raise TimeoutError
404
+ if errors:
405
+ raise errors[0]
406
+ for family, kind, protocol, _, address in addresses:
407
+ connection = socket.socket(family, kind, protocol)
408
+ try:
409
+ connection.settimeout(_remaining(deadline))
410
+ connection.connect(address)
411
+ if secure:
412
+ context = ssl.create_default_context()
413
+ connection.settimeout(_remaining(deadline))
414
+ connection = context.wrap_socket(connection, server_hostname=host)
415
+ return connection
416
+ except OSError:
417
+ connection.close()
418
+ raise OSError
419
+
420
+
421
+ def send_once(
422
+ request: DeliveryRequest, *, env: Mapping[str, str] | None = None
423
+ ) -> Disposition:
424
+ """One bounded HTTP attempt; no redirects, retries, or stored credentials."""
425
+ env = os.environ if env is None else env
426
+ delivery_id = str(uuid.uuid4())
427
+ try:
428
+ request = _validated(request)
429
+ delivery_id = request.delivery_id
430
+ token = _configuration(request, env)
431
+ except DeliveryError as exc:
432
+ return _report(Disposition(delivery_id, "blocked", exc.reason))
433
+ parsed = urllib.parse.urlsplit(request.destination)
434
+ connection_type = (
435
+ http.client.HTTPSConnection
436
+ if parsed.scheme == "https"
437
+ else http.client.HTTPConnection
438
+ )
439
+ connection = connection_type(
440
+ parsed.hostname, parsed.port, timeout=HTTP_TIMEOUT_SECONDS
441
+ )
442
+ timer = None
443
+ try:
444
+ deadline = time.monotonic() + HTTP_TIMEOUT_SECONDS
445
+ transport = _connect(
446
+ parsed.hostname,
447
+ parsed.port or (443 if parsed.scheme == "https" else 80),
448
+ secure=parsed.scheme == "https",
449
+ deadline=deadline,
450
+ )
451
+ connection.sock = transport
452
+
453
+ def interrupt():
454
+ try:
455
+ transport.shutdown(socket.SHUT_RDWR)
456
+ except OSError:
457
+ pass
458
+
459
+ # An inactivity timeout alone permits an endless slow response/header.
460
+ timer = threading.Timer(_remaining(deadline), interrupt)
461
+ timer.daemon = True
462
+ timer.start()
463
+ connection.request(
464
+ "POST",
465
+ parsed.path,
466
+ body=request.body,
467
+ headers={
468
+ "Content-Type": "application/json",
469
+ "User-Agent": "sediment-delivery/1",
470
+ "Authorization": f"Bearer {token}",
471
+ },
472
+ )
473
+ response = connection.getresponse()
474
+ code = response.status
475
+ if code in {408, 429} or 500 <= code <= 599:
476
+ result = Disposition(delivery_id, "pending", "retryable_http")
477
+ elif code in {401, 403}:
478
+ result = Disposition(delivery_id, "blocked", "authentication_rejected")
479
+ elif 300 <= code <= 399:
480
+ result = Disposition(delivery_id, "blocked", "redirect_rejected")
481
+ elif 400 <= code <= 499:
482
+ result = Disposition(delivery_id, "blocked", "client_rejected")
483
+ elif code != 200:
484
+ result = Disposition(delivery_id, "pending", "invalid_acknowledgment")
485
+ else:
486
+ body = bytearray()
487
+ while len(body) <= MAX_RESPONSE_BYTES:
488
+ remaining = deadline - time.monotonic()
489
+ if remaining <= 0:
490
+ raise TimeoutError
491
+ if connection.sock is not None:
492
+ connection.sock.settimeout(remaining)
493
+ part = response.read1(min(8192, MAX_RESPONSE_BYTES + 1 - len(body)))
494
+ if not part:
495
+ break
496
+ body.extend(part)
497
+ _remaining(deadline)
498
+ if response.length not in {None, 0} and len(body) <= MAX_RESPONSE_BYTES:
499
+ raise http.client.IncompleteRead(bytes(body))
500
+ result = (
501
+ _acknowledgment(request, bytes(body))
502
+ if len(body) <= MAX_RESPONSE_BYTES
503
+ else Disposition(delivery_id, "pending", "invalid_acknowledgment")
504
+ )
505
+ except (OSError, http.client.HTTPException, ValueError):
506
+ result = Disposition(delivery_id, "pending", "transport_failure")
507
+ finally:
508
+ if timer is not None:
509
+ timer.cancel()
510
+ connection.close()
511
+ return _report(result)
512
+
513
+
514
+ class _Queue:
515
+ """An opened private directory; relative operations cannot follow record links."""
516
+
517
+ def __init__(self, path: str | Path, *, create: bool):
518
+ self.path = Path(path)
519
+ self.fd = -1
520
+ if not self.path.is_absolute():
521
+ raise DeliveryError("unsafe_storage")
522
+ try:
523
+ self.fd = self._open_directory(create=create)
524
+ self._check(os.fstat(self.fd), directory=True)
525
+ for name in os.listdir(self.fd):
526
+ try:
527
+ self._check(os.stat(name, dir_fd=self.fd, follow_symlinks=False))
528
+ except FileNotFoundError:
529
+ # Another publisher/worker may have renamed or removed it.
530
+ pass
531
+ except Exception:
532
+ if self.fd != -1:
533
+ os.close(self.fd)
534
+ self.fd = -1
535
+ raise
536
+
537
+ def _open_directory(self, *, create: bool) -> int:
538
+ parts = list(self.path.parts[1:])
539
+ if ".." in parts:
540
+ raise DeliveryError("unsafe_storage")
541
+ # macOS exposes these root-owned OS aliases. All remaining components,
542
+ # including user-created ancestor links, are opened without following links.
543
+ if sys.platform == "darwin" and parts and parts[0] in {"tmp", "var"}:
544
+ alias = Path("/") / parts[0]
545
+ info = alias.lstat()
546
+ if (
547
+ stat.S_ISLNK(info.st_mode)
548
+ and info.st_uid == 0
549
+ and os.readlink(alias) == "private/" + parts[0]
550
+ ):
551
+ parts.insert(0, "private")
552
+ directory = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
553
+ try:
554
+ for index, part in enumerate(parts):
555
+ try:
556
+ child = os.open(
557
+ part,
558
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
559
+ dir_fd=directory,
560
+ )
561
+ except FileNotFoundError:
562
+ if not create:
563
+ raise
564
+ try:
565
+ os.mkdir(part, 0o700, dir_fd=directory)
566
+ os.fsync(directory)
567
+ except FileExistsError:
568
+ pass
569
+ child = os.open(
570
+ part,
571
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
572
+ dir_fd=directory,
573
+ )
574
+ os.close(directory)
575
+ directory = child
576
+ info = os.fstat(directory)
577
+ if index != len(parts) - 1 and (
578
+ info.st_uid not in {0, os.getuid()}
579
+ or (
580
+ stat.S_IMODE(info.st_mode) & 0o022
581
+ and not (info.st_uid == 0 and info.st_mode & stat.S_ISVTX)
582
+ )
583
+ ):
584
+ raise DeliveryError("unsafe_storage")
585
+ return directory
586
+ except Exception:
587
+ os.close(directory)
588
+ raise
589
+
590
+ @staticmethod
591
+ def _check(info, *, directory=False):
592
+ correct_type = (
593
+ stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)
594
+ )
595
+ mode = 0o700 if directory else 0o600
596
+ if (
597
+ not correct_type
598
+ or info.st_uid != os.getuid()
599
+ or stat.S_IMODE(info.st_mode) != mode
600
+ or (not directory and info.st_nlink != 1)
601
+ ):
602
+ raise DeliveryError("unsafe_storage")
603
+
604
+ def __enter__(self):
605
+ return self
606
+
607
+ def __exit__(self, *args):
608
+ os.close(self.fd)
609
+
610
+ def read(self, name: str, limit: int) -> bytes:
611
+ fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=self.fd)
612
+ with os.fdopen(fd, "rb") as stream:
613
+ self._check(os.fstat(stream.fileno()))
614
+ data = stream.read(limit + 1)
615
+ if len(data) > limit:
616
+ raise DeliveryError("invalid_record")
617
+ return data
618
+
619
+ def atomic(self, name: str, data: bytes):
620
+ temporary = f".tmp-{uuid.uuid4()}"
621
+ fd = os.open(
622
+ temporary,
623
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
624
+ 0o600,
625
+ dir_fd=self.fd,
626
+ )
627
+ try:
628
+ with os.fdopen(fd, "wb") as stream:
629
+ stream.write(data)
630
+ stream.flush()
631
+ os.fsync(stream.fileno())
632
+ os.rename(temporary, name, src_dir_fd=self.fd, dst_dir_fd=self.fd)
633
+ os.fsync(self.fd)
634
+ finally:
635
+ self.remove(temporary)
636
+
637
+ def remove(self, name):
638
+ try:
639
+ os.unlink(name, dir_fd=self.fd)
640
+ except FileNotFoundError:
641
+ pass
642
+
643
+ @contextmanager
644
+ def lock(self, name: str, *, wait: bool = True, create: bool = True):
645
+ flags = os.O_RDWR | os.O_NOFOLLOW | (os.O_CREAT if create else 0)
646
+ fd = os.open(name, flags, 0o600, dir_fd=self.fd)
647
+ try:
648
+ self._check(os.fstat(fd))
649
+ deadline = time.monotonic() + 1
650
+ while True:
651
+ try:
652
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
653
+ break
654
+ except BlockingIOError:
655
+ if not wait or time.monotonic() >= deadline:
656
+ raise DeliveryError(
657
+ "worker_busy" if name == "worker.lock" else "storage_busy"
658
+ ) from None
659
+ time.sleep(0.01)
660
+ yield
661
+ finally:
662
+ os.close(fd)
663
+
664
+ def names(self, suffix):
665
+ return sorted(name for name in os.listdir(self.fd) if name.endswith(suffix))
666
+
667
+
668
+ def _failure(exc: Exception) -> str:
669
+ if isinstance(exc, DeliveryError):
670
+ return exc.reason
671
+ if isinstance(exc, OSError) and exc.errno in {40, 62, 20}:
672
+ return "unsafe_storage"
673
+ return "storage_unavailable"
674
+
675
+
676
+ def _header(queue: _Queue, name: str) -> dict:
677
+ fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=queue.fd)
678
+ with os.fdopen(fd, "rb") as stream:
679
+ info = os.fstat(stream.fileno())
680
+ queue._check(info)
681
+ line = stream.readline(_MAX_HEADER_BYTES + 1)
682
+ try:
683
+ header = _json(line)
684
+ required = {
685
+ "format_version",
686
+ "delivery_id",
687
+ "channel",
688
+ "destination",
689
+ "captured_at",
690
+ "enqueued_at",
691
+ "bytes",
692
+ "sha256",
693
+ }
694
+ if (
695
+ not line.endswith(b"\n")
696
+ or len(line) > _MAX_HEADER_BYTES
697
+ or not isinstance(header, dict)
698
+ or set(header) != required
699
+ ):
700
+ raise ValueError
701
+ if (
702
+ type(header["format_version"]) is not int
703
+ or header["format_version"] != FORMAT_VERSION
704
+ ):
705
+ raise ValueError
706
+ if (
707
+ _uuid(header["delivery_id"]) + ".entry" != name
708
+ or _destination(header["channel"], header["destination"])
709
+ != header["destination"]
710
+ ):
711
+ raise ValueError
712
+ _instant(header["captured_at"])
713
+ if (
714
+ not _number(header["enqueued_at"])
715
+ or type(header["bytes"]) is not int
716
+ or not 0 <= header["bytes"] <= MAX_ENTRY_BYTES
717
+ ):
718
+ raise ValueError
719
+ if (
720
+ info.st_size != len(line) + header["bytes"]
721
+ or not isinstance(header["sha256"], str)
722
+ or not re.fullmatch("[0-9a-f]{64}", header["sha256"])
723
+ ):
724
+ raise ValueError
725
+ except (ValueError, TypeError, KeyError):
726
+ raise DeliveryError("invalid_record") from None
727
+ return header
728
+
729
+
730
+ def _number(value):
731
+ try:
732
+ return type(value) in {int, float} and math.isfinite(value) and value >= 0
733
+ except OverflowError:
734
+ return False
735
+
736
+
737
+ def _request(queue: _Queue, header: dict) -> DeliveryRequest:
738
+ data = queue.read(
739
+ header["delivery_id"] + ".entry", MAX_ENTRY_BYTES + _MAX_HEADER_BYTES
740
+ )
741
+ _, _, body = data.partition(b"\n")
742
+ if (
743
+ len(body) != header["bytes"]
744
+ or hashlib.sha256(body).hexdigest() != header["sha256"]
745
+ ):
746
+ raise DeliveryError("invalid_record")
747
+ return prepare_request(
748
+ header["channel"],
749
+ header["destination"],
750
+ body,
751
+ captured_at=header["captured_at"],
752
+ delivery_id=header["delivery_id"],
753
+ )
754
+
755
+
756
+ def _state(queue: _Queue, delivery_id: str) -> dict:
757
+ try:
758
+ state = _json(queue.read(delivery_id + ".state", _MAX_HEADER_BYTES))
759
+ except FileNotFoundError:
760
+ return {
761
+ "status": "pending",
762
+ "attempts": 0,
763
+ "next_attempt": 0,
764
+ "reason": "buffered",
765
+ }
766
+ except (ValueError, UnicodeError):
767
+ raise DeliveryError("invalid_record") from None
768
+ if (
769
+ not isinstance(state, dict)
770
+ or set(state) != {"status", "attempts", "next_attempt", "reason"}
771
+ or not isinstance(state["status"], str)
772
+ or state["status"] not in {"pending", "blocked"}
773
+ or type(state["attempts"]) is not int
774
+ or state["attempts"] < 0
775
+ or not _number(state["next_attempt"])
776
+ or not isinstance(state["reason"], str)
777
+ or state["reason"] not in _REASONS
778
+ ):
779
+ raise DeliveryError("invalid_record")
780
+ return state
781
+
782
+
783
+ def _receipt(queue: _Queue, disposition: Disposition, now: float):
784
+ queue.atomic(
785
+ disposition.delivery_id + ".receipt",
786
+ _encoded({**disposition.as_dict(), "completed_at": now}),
787
+ )
788
+ queue.remove(disposition.delivery_id + ".entry")
789
+ queue.remove(disposition.delivery_id + ".state")
790
+ os.fsync(queue.fd)
791
+
792
+
793
+ def _read_receipt(queue: _Queue, name: str) -> dict:
794
+ try:
795
+ receipt = _json(queue.read(name, _MAX_HEADER_BYTES))
796
+ required = {"format_version", "delivery_id", "status", "reason", "completed_at"}
797
+ if not isinstance(receipt, dict) or set(receipt) not in (
798
+ required,
799
+ required | {"fact_id", "stored"},
800
+ ):
801
+ raise ValueError
802
+ if (
803
+ type(receipt["format_version"]) is not int
804
+ or receipt["format_version"] != FORMAT_VERSION
805
+ ):
806
+ raise ValueError
807
+ if _uuid(receipt["delivery_id"]) + ".receipt" != name or not _number(
808
+ receipt["completed_at"]
809
+ ):
810
+ raise ValueError
811
+ status, reason = receipt["status"], receipt["reason"]
812
+ if (
813
+ not isinstance(status, str)
814
+ or status not in _TERMINAL
815
+ or not isinstance(reason, str)
816
+ or reason not in _REASONS
817
+ ):
818
+ raise ValueError
819
+ if status == "acknowledged" and reason in {
820
+ "gateway_stored",
821
+ "gateway_duplicate",
822
+ }:
823
+ fact_id = receipt.get("fact_id")
824
+ if (
825
+ not isinstance(fact_id, str)
826
+ or not fact_id
827
+ or fact_id.strip() != fact_id
828
+ or len(fact_id) > 1024
829
+ or any(ord(c) < 32 for c in fact_id)
830
+ or type(receipt.get("stored")) is not bool
831
+ or receipt["stored"] != (reason == "gateway_stored")
832
+ ):
833
+ raise ValueError
834
+ elif (
835
+ set(receipt) != required
836
+ or (status == "acknowledged" and reason != "otlp_delivered")
837
+ or (status == "skipped" and reason != "no_session")
838
+ or (
839
+ status == "declined"
840
+ and reason not in {"buffer_full", "expired", "invalid_record"}
841
+ )
842
+ ):
843
+ raise ValueError
844
+ return receipt
845
+ except (ValueError, TypeError, KeyError):
846
+ raise DeliveryError("invalid_record") from None
847
+
848
+
849
+ def _maintenance(queue: _Queue, now: float):
850
+ # Validate before any deletion: a malformed receipt cannot erase its payload.
851
+ receipts = {name: _read_receipt(queue, name) for name in queue.names(".receipt")}
852
+ # Enqueue lock excludes unfinished publishers. Only our temporary names go.
853
+ for name in os.listdir(queue.fd):
854
+ if re.fullmatch(r"\.tmp-[0-9a-f-]{36}", name):
855
+ queue.remove(name)
856
+ for name in queue.names(".entry"):
857
+ delivery_id = name.removesuffix(".entry")
858
+ if (
859
+ not re.fullmatch(r"[0-9a-f-]{36}", delivery_id)
860
+ or _uuid(delivery_id) != delivery_id
861
+ ):
862
+ raise DeliveryError("invalid_record")
863
+ if delivery_id + ".receipt" in receipts:
864
+ # A crash after receipt publication can leave the acknowledged body.
865
+ queue.remove(name)
866
+ queue.remove(delivery_id + ".state")
867
+ continue
868
+ try:
869
+ header = _header(queue, name)
870
+ if now - header["enqueued_at"] < REPLAY_WINDOW_SECONDS:
871
+ continue
872
+ reason = "expired"
873
+ except DeliveryError as exc:
874
+ if exc.reason != "invalid_record":
875
+ raise
876
+ reason = "invalid_record"
877
+ _receipt(queue, _report(Disposition(delivery_id, "declined", reason)), now)
878
+ retained = []
879
+ for name in queue.names(".receipt"):
880
+ completed = _read_receipt(queue, name)["completed_at"]
881
+ if now - completed >= RECEIPT_RETENTION_SECONDS:
882
+ queue.remove(name)
883
+ else:
884
+ retained.append((completed, name))
885
+ for _, name in sorted(retained)[: max(0, len(retained) - MAX_RECEIPTS)]:
886
+ queue.remove(name)
887
+ os.fsync(queue.fd)
888
+
889
+
890
+ def enqueue(request: DeliveryRequest, directory: str | Path) -> Disposition:
891
+ """Publish exact bytes and fsync before returning queued; never perform HTTP."""
892
+ delivery_id = str(uuid.uuid4())
893
+ try:
894
+ request = _validated(request)
895
+ delivery_id = request.delivery_id
896
+ with _Queue(directory, create=True) as queue, queue.lock("enqueue.lock"):
897
+ now = time.time()
898
+ _maintenance(queue, now)
899
+ names = queue.names(".entry")
900
+ if delivery_id + ".entry" in names:
901
+ original = _request(queue, _header(queue, delivery_id + ".entry"))
902
+ if original != request:
903
+ return _report(
904
+ Disposition(delivery_id, "declined", "identity_conflict")
905
+ )
906
+ return _report(Disposition(delivery_id, "queued", "already_buffered"))
907
+ if delivery_id + ".receipt" in queue.names(".receipt"):
908
+ return _report(
909
+ Disposition(delivery_id, "declined", "identity_conflict")
910
+ )
911
+ used = sum(_header(queue, name)["bytes"] for name in names)
912
+ if (
913
+ len(names) >= MAX_ACTIVE_ENTRIES
914
+ or used + len(request.body) > MAX_ACTIVE_BYTES
915
+ ):
916
+ result = Disposition(delivery_id, "declined", "buffer_full")
917
+ try:
918
+ _receipt(queue, result, now)
919
+ _maintenance(queue, now)
920
+ except (OSError, ValueError) as exc:
921
+ # A failed diagnostic must not erase the capacity decision
922
+ # and make the caller treat this as eligible for fallback.
923
+ logger.warning(
924
+ "delivery_receipt_failed delivery_id=%s reason=%s disposition=%s",
925
+ delivery_id,
926
+ _failure(exc),
927
+ result.reason,
928
+ )
929
+ return _report(result)
930
+ header = {
931
+ "format_version": FORMAT_VERSION,
932
+ "delivery_id": delivery_id,
933
+ "channel": request.channel,
934
+ "destination": request.destination,
935
+ "captured_at": request.captured_at,
936
+ "enqueued_at": now,
937
+ "bytes": len(request.body),
938
+ "sha256": hashlib.sha256(request.body).hexdigest(),
939
+ }
940
+ queue.atomic(
941
+ delivery_id + ".entry", _encoded(header) + b"\n" + request.body
942
+ )
943
+ return _report(Disposition(delivery_id, "queued", "buffered"))
944
+ except (OSError, ValueError) as exc:
945
+ return _report(Disposition(delivery_id, "declined", _failure(exc)))
946
+
947
+
948
+ def deliver(
949
+ request: DeliveryRequest, *, env: Mapping[str, str] | None = None
950
+ ) -> Disposition:
951
+ """Enqueue when enabled; storage faults degrade to one best-effort attempt."""
952
+ env = os.environ if env is None else env
953
+ directory = env.get("SEDIMENT_DELIVERY_DIR", "").strip()
954
+ if directory:
955
+ result = enqueue(request, directory)
956
+ if result.status != "declined" or result.reason not in {
957
+ "unsafe_storage",
958
+ "storage_unavailable",
959
+ "storage_busy",
960
+ }:
961
+ return result
962
+ logger.warning(
963
+ "delivery_mode delivery_id=%s mode=best_effort reason=%s",
964
+ result.delivery_id,
965
+ result.reason,
966
+ )
967
+ return replace(send_once(request, env=env), fallback_reason=result.reason)
968
+ logger.warning("delivery_mode best_effort reason=buffer_disabled")
969
+ return send_once(request, env=env)
970
+
971
+
972
+ def _summary() -> dict:
973
+ return {
974
+ "format_version": FORMAT_VERSION,
975
+ "attempted": 0,
976
+ "acknowledged": 0,
977
+ "skipped": 0,
978
+ "pending": 0,
979
+ "blocked": 0,
980
+ "worker_busy": False,
981
+ }
982
+
983
+
984
+ def _batch(
985
+ queue: _Queue,
986
+ env: Mapping[str, str],
987
+ *,
988
+ retry_blocked: bool,
989
+ stop_event: threading.Event | None = None,
990
+ ) -> dict:
991
+ summary = _summary()
992
+ with queue.lock("enqueue.lock"):
993
+ _maintenance(queue, time.time())
994
+ candidates = []
995
+ for name in queue.names(".entry"):
996
+ header = _header(queue, name)
997
+ delivery_id = header["delivery_id"]
998
+ try:
999
+ due = (
1000
+ _state(queue, delivery_id)["next_attempt"] or header["enqueued_at"]
1001
+ )
1002
+ except DeliveryError as exc:
1003
+ if exc.reason != "invalid_record":
1004
+ raise
1005
+ _receipt(
1006
+ queue,
1007
+ _report(Disposition(delivery_id, "declined", "invalid_record")),
1008
+ time.time(),
1009
+ )
1010
+ continue
1011
+ candidates.append((due, header))
1012
+ # Schedule by due time, including enqueue time for untouched entries.
1013
+ # Slow failures cannot reclaim every slot, nor can new arrivals starve retries.
1014
+ headers = [
1015
+ header
1016
+ for _, header in sorted(
1017
+ candidates,
1018
+ key=lambda pair: (
1019
+ pair[0],
1020
+ pair[1]["enqueued_at"],
1021
+ pair[1]["delivery_id"],
1022
+ ),
1023
+ )
1024
+ ]
1025
+ for header in headers:
1026
+ if summary["attempted"] >= MAX_BATCH_ATTEMPTS or (
1027
+ stop_event is not None and stop_event.is_set()
1028
+ ):
1029
+ break
1030
+ delivery_id = header["delivery_id"]
1031
+ with queue.lock("enqueue.lock"):
1032
+ if delivery_id + ".entry" not in queue.names(".entry"):
1033
+ continue
1034
+ if time.time() - header["enqueued_at"] >= REPLAY_WINDOW_SECONDS:
1035
+ _receipt(
1036
+ queue,
1037
+ _report(Disposition(delivery_id, "declined", "expired")),
1038
+ time.time(),
1039
+ )
1040
+ continue
1041
+ try:
1042
+ state = _state(queue, delivery_id)
1043
+ if state["status"] == "blocked" and not retry_blocked:
1044
+ continue
1045
+ if state["status"] != "blocked" and state["next_attempt"] > time.time():
1046
+ continue
1047
+ request = _request(queue, header)
1048
+ except DeliveryError as exc:
1049
+ if exc.reason != "invalid_record":
1050
+ raise
1051
+ _receipt(
1052
+ queue,
1053
+ _report(Disposition(delivery_id, "declined", "invalid_record")),
1054
+ time.time(),
1055
+ )
1056
+ continue
1057
+ try:
1058
+ _configuration(request, env)
1059
+ except DeliveryError as exc:
1060
+ result = _report(Disposition(delivery_id, "blocked", exc.reason))
1061
+ else:
1062
+ result = send_once(request, env=env)
1063
+ summary["attempted"] += 1
1064
+ with queue.lock("enqueue.lock"):
1065
+ now = time.time()
1066
+ # An enqueue can expire this entry while HTTP runs outside the lock.
1067
+ # Its terminal receipt wins; do not resurrect a deleted transport copy.
1068
+ if delivery_id + ".entry" not in queue.names(".entry"):
1069
+ continue
1070
+ if result.status in _TERMINAL:
1071
+ _receipt(queue, result, now)
1072
+ else:
1073
+ attempts = state["attempts"] + 1
1074
+ ceiling = min(MAX_BACKOFF_SECONDS, 2 ** min(attempts, 6))
1075
+ delay = ceiling / 2 + secrets.randbelow(1001) / 1000 * ceiling / 2
1076
+ queue.atomic(
1077
+ delivery_id + ".state",
1078
+ _encoded(
1079
+ {
1080
+ "status": result.status,
1081
+ "attempts": attempts,
1082
+ "next_attempt": now + delay,
1083
+ "reason": result.reason,
1084
+ }
1085
+ ),
1086
+ )
1087
+ summary[result.status] += 1
1088
+ with queue.lock("enqueue.lock"):
1089
+ _maintenance(queue, time.time())
1090
+ return summary
1091
+
1092
+
1093
+ def replay(
1094
+ directory: str | Path,
1095
+ *,
1096
+ env: Mapping[str, str] | None = None,
1097
+ retry_blocked: bool = False,
1098
+ ) -> dict:
1099
+ """Drain up to 32 due entries once each; a failed entry never monopolizes work."""
1100
+ env = os.environ if env is None else env
1101
+ with _Queue(directory, create=False) as queue:
1102
+ try:
1103
+ with queue.lock("worker.lock", wait=False):
1104
+ return _batch(queue, env, retry_blocked=retry_blocked)
1105
+ except DeliveryError as exc:
1106
+ if exc.reason != "worker_busy":
1107
+ raise
1108
+ return {**_summary(), "worker_busy": True}
1109
+
1110
+
1111
+ def watch(
1112
+ directory: str | Path,
1113
+ *,
1114
+ env: Mapping[str, str] | None = None,
1115
+ stop_event: threading.Event | None = None,
1116
+ retry_blocked: bool = False,
1117
+ ) -> dict:
1118
+ """Own one worker until stopped, finishing at most its in-flight HTTP attempt.
1119
+
1120
+ An explicitly enrolled worker initializes its private directory and locks
1121
+ before the first enqueue. Status never initializes storage.
1122
+ """
1123
+ env = os.environ if env is None else env
1124
+ stopped = threading.Event() if stop_event is None else stop_event
1125
+ result = _summary()
1126
+ while not stopped.is_set():
1127
+ try:
1128
+ queue = _Queue(directory, create=True)
1129
+ except (OSError, ValueError) as exc:
1130
+ logger.warning("delivery_worker reason=%s", _failure(exc))
1131
+ return {**result, "error": _failure(exc)}
1132
+ with queue:
1133
+ try:
1134
+ with queue.lock("worker.lock", wait=False):
1135
+ while not stopped.is_set():
1136
+ try:
1137
+ result = _batch(
1138
+ queue,
1139
+ env,
1140
+ retry_blocked=retry_blocked,
1141
+ stop_event=stopped,
1142
+ )
1143
+ retry_blocked = False
1144
+ except (OSError, ValueError) as exc:
1145
+ logger.warning("delivery_worker reason=%s", _failure(exc))
1146
+ result = {**_summary(), "error": _failure(exc)}
1147
+ stopped.wait(1)
1148
+ except (OSError, ValueError) as exc:
1149
+ if isinstance(exc, DeliveryError) and exc.reason == "worker_busy":
1150
+ return {**result, "worker_busy": True}
1151
+ logger.warning("delivery_worker reason=%s", _failure(exc))
1152
+ return {**result, "error": _failure(exc)}
1153
+ return result
1154
+
1155
+
1156
+ def status(directory: str | Path) -> dict:
1157
+ """Inspect without creating directories, records, or worker enrollment."""
1158
+ result = {
1159
+ "format_version": FORMAT_VERSION,
1160
+ "pending": 0,
1161
+ "pending_bytes": 0,
1162
+ "oldest_pending_age_seconds": None,
1163
+ "blocked": 0,
1164
+ "blocked_bytes": 0,
1165
+ "active_bytes": 0,
1166
+ "receipts": {},
1167
+ "worker_running": False,
1168
+ }
1169
+ try:
1170
+ queue = _Queue(directory, create=False)
1171
+ except FileNotFoundError:
1172
+ return result
1173
+ with queue:
1174
+ try:
1175
+ with queue.lock("worker.lock", wait=False, create=False):
1176
+ pass
1177
+ except FileNotFoundError:
1178
+ pass
1179
+ except DeliveryError as exc:
1180
+ if exc.reason != "worker_busy":
1181
+ raise
1182
+ result["worker_running"] = True
1183
+ if "enqueue.lock" not in os.listdir(queue.fd):
1184
+ if (
1185
+ queue.names(".entry")
1186
+ or queue.names(".receipt")
1187
+ or queue.names(".state")
1188
+ ):
1189
+ raise DeliveryError("invalid_record")
1190
+ return result
1191
+ with queue.lock("enqueue.lock", create=False):
1192
+ now, ages = time.time(), []
1193
+ receipt_names = {
1194
+ name: _read_receipt(queue, name) for name in queue.names(".receipt")
1195
+ }
1196
+ for name in queue.names(".entry"):
1197
+ header = _header(queue, name)
1198
+ if header["delivery_id"] + ".receipt" in receipt_names:
1199
+ continue
1200
+ state = _state(queue, header["delivery_id"])
1201
+ category = "blocked" if state["status"] == "blocked" else "pending"
1202
+ result[category] += 1
1203
+ result[category + "_bytes"] += header["bytes"]
1204
+ result["active_bytes"] += header["bytes"]
1205
+ if category == "pending":
1206
+ ages.append(max(0, now - header["enqueued_at"]))
1207
+ result["oldest_pending_age_seconds"] = max(ages) if ages else None
1208
+ counts = Counter(receipt["reason"] for receipt in receipt_names.values())
1209
+ result["receipts"] = dict(sorted(counts.items()))
1210
+ return result
1211
+
1212
+
1213
+ def build_parser(*, prog: str | None = None) -> argparse.ArgumentParser:
1214
+ parser = argparse.ArgumentParser(
1215
+ prog=prog,
1216
+ description="Deliver prepared capture payloads; OTLP acknowledgment is not a Fact count.",
1217
+ )
1218
+ sub = parser.add_subparsers(dest="command", required=True)
1219
+ for command, help_text in (
1220
+ ("enqueue", "durably enqueue a bounded JSON request from stdin"),
1221
+ ("status", "inspect private buffer and worker state"),
1222
+ ("replay", "replay a bounded batch of due payloads"),
1223
+ ):
1224
+ child = sub.add_parser(command, help=help_text)
1225
+ child.add_argument(
1226
+ "--directory",
1227
+ default=None,
1228
+ help="private buffer (default: SEDIMENT_DELIVERY_DIR)",
1229
+ )
1230
+ if command == "enqueue":
1231
+ child.add_argument(
1232
+ "--fallback-direct",
1233
+ action="store_true",
1234
+ help="allow one best-effort send when buffering is disabled or storage fails",
1235
+ )
1236
+ if command == "replay":
1237
+ child.add_argument(
1238
+ "--watch", action="store_true", help="supervise replay until stopped"
1239
+ )
1240
+ child.add_argument(
1241
+ "--retry-blocked",
1242
+ action="store_true",
1243
+ help="retry blocked payloads once; stop the existing worker first",
1244
+ )
1245
+ return parser
1246
+
1247
+
1248
+ def request_from_dict(value: dict) -> DeliveryRequest:
1249
+ if (
1250
+ not isinstance(value, dict)
1251
+ or not {"channel", "destination", "body_base64"} <= set(value)
1252
+ or set(value)
1253
+ - {"channel", "destination", "body_base64", "captured_at", "delivery_id"}
1254
+ ):
1255
+ raise DeliveryError("invalid_request")
1256
+ if (
1257
+ not isinstance(value["body_base64"], str)
1258
+ or len(value["body_base64"]) > ((MAX_ENTRY_BYTES + 2) // 3) * 4
1259
+ ):
1260
+ raise DeliveryError("entry_too_large")
1261
+ try:
1262
+ body = base64.b64decode(value["body_base64"], validate=True)
1263
+ except (ValueError, binascii.Error):
1264
+ raise DeliveryError("invalid_request") from None
1265
+ return prepare_request(
1266
+ value["channel"],
1267
+ value["destination"],
1268
+ body,
1269
+ captured_at=value.get("captured_at"),
1270
+ delivery_id=value.get("delivery_id"),
1271
+ )
1272
+
1273
+
1274
+ def main(argv: list[str] | None = None, *, prog: str | None = None) -> int:
1275
+ args = build_parser(prog=prog).parse_args(argv)
1276
+ directory = (
1277
+ args.directory
1278
+ if args.directory is not None
1279
+ else os.environ.get("SEDIMENT_DELIVERY_DIR", "")
1280
+ ).strip()
1281
+ fallback = args.command == "enqueue" and args.fallback_direct
1282
+ try:
1283
+ if not directory and not fallback:
1284
+ raise DeliveryError("buffer_disabled")
1285
+ if args.command == "enqueue":
1286
+ data = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1)
1287
+ if len(data) > MAX_STDIN_BYTES:
1288
+ raise DeliveryError("entry_too_large")
1289
+ try:
1290
+ request = request_from_dict(_json(data))
1291
+ except (ValueError, TypeError, RecursionError) as exc:
1292
+ raise DeliveryError(
1293
+ exc.reason if isinstance(exc, DeliveryError) else "invalid_request"
1294
+ ) from None
1295
+ result = (
1296
+ deliver(request, env={**os.environ, "SEDIMENT_DELIVERY_DIR": directory})
1297
+ if fallback
1298
+ else enqueue(request, directory)
1299
+ )
1300
+ print(json.dumps(result.as_dict(), sort_keys=True))
1301
+ return (
1302
+ 0
1303
+ if result.status == "queued"
1304
+ or (fallback and result.status in {"acknowledged", "skipped"})
1305
+ else 1
1306
+ )
1307
+ if args.command == "status":
1308
+ print(json.dumps(status(directory), sort_keys=True))
1309
+ return 0
1310
+ stopped = threading.Event()
1311
+ prior = {}
1312
+ if args.watch:
1313
+ for signum in (signal.SIGINT, signal.SIGTERM):
1314
+ prior[signum] = signal.signal(signum, lambda *_: stopped.set())
1315
+ try:
1316
+ result = (
1317
+ watch(directory, stop_event=stopped, retry_blocked=args.retry_blocked)
1318
+ if args.watch
1319
+ else replay(directory, retry_blocked=args.retry_blocked)
1320
+ )
1321
+ print(json.dumps(result, sort_keys=True), flush=True)
1322
+ return 1 if "error" in result else 0
1323
+ finally:
1324
+ for signum, handler in prior.items():
1325
+ signal.signal(signum, handler)
1326
+ except (OSError, ValueError) as exc:
1327
+ reason = _failure(exc)
1328
+ result = _report(Disposition(str(uuid.uuid4()), "declined", reason))
1329
+ print(json.dumps(result.as_dict(), sort_keys=True))
1330
+ return 1
1331
+
1332
+
1333
+ if __name__ == "__main__":
1334
+ sys.exit(main())