tokenmeter-sdk 0.2.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,82 @@
1
+ """HTTP transport: POST /v1/events:batch (PRD §7.5). stdlib only -- no requests/httpx
2
+ dependency for what a dozen lines of urllib does.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import gzip
7
+ import json
8
+ import logging
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import List
12
+
13
+ from ._ids import uuid7_str
14
+
15
+ logger = logging.getLogger("tokenmeter")
16
+
17
+ SDK_NAME = "python"
18
+ SDK_VERSION = "0.2.0"
19
+ SCHEMA_HASH = "v1" # ponytail: bump when the wire event shape changes; not computed, just pinned
20
+
21
+
22
+ class Transport:
23
+ def __init__(self, ingest_key: str, endpoint: str, timeout_s: float = 15.0) -> None:
24
+ self.ingest_key = ingest_key
25
+ self.endpoint = endpoint.rstrip("/") + "/v1/events:batch"
26
+ self.timeout_s = timeout_s
27
+
28
+ def send(self, events: List[dict], dropped_events: int) -> None:
29
+ """Raises on failure -- callers (Flusher) are responsible for swallowing.
30
+ Keeping this method "honest" (it raises) is what makes the
31
+ never-throws-into-host-code test meaningful: the guarantee is enforced at
32
+ the buffer/flusher boundary, not by this function lying about success.
33
+ """
34
+ body = {
35
+ "batch_id": uuid7_str(),
36
+ "sdk": {"name": SDK_NAME, "version": SDK_VERSION, "schema_hash": SCHEMA_HASH},
37
+ "dropped_since_last_ack": dropped_events,
38
+ "events": events,
39
+ }
40
+ payload = gzip.compress(json.dumps(body, default=str).encode("utf-8"))
41
+ req = urllib.request.Request(
42
+ self.endpoint,
43
+ data=payload,
44
+ method="POST",
45
+ headers={
46
+ "Authorization": f"Bearer {self.ingest_key}",
47
+ "Content-Type": "application/json",
48
+ "Content-Encoding": "gzip",
49
+ # PRD §7.1: drops are shipped visibly, never silent -- header too,
50
+ # so a proxy/log line can see it without parsing the gzip body.
51
+ "X-TL-Dropped-Events": str(dropped_events),
52
+ },
53
+ )
54
+ with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
55
+ if resp.status >= 300:
56
+ raise RuntimeError(f"tokenmeter ingest returned HTTP {resp.status}")
57
+
58
+ def ping(self) -> dict:
59
+ """Used by diagnose(): a zero-event batch, whose only purpose is to
60
+ surface auth failures loudly. Raises urllib.error.HTTPError on 401/403.
61
+
62
+ Ingest authenticates the key before it reads the body, then rejects an
63
+ empty events array with 400 invalid_envelope (packages/shared
64
+ batchEnvelope.ts). So that 400 is the expected answer for a valid key:
65
+ auth passed and nothing was written. Any other 400 still raises.
66
+ """
67
+ try:
68
+ self.send(events=[], dropped_events=0)
69
+ return {"ok": True, "endpoint": self.endpoint, "status": 202}
70
+ except urllib.error.HTTPError as e:
71
+ if e.code == 400 and _error_code(e) == "invalid_envelope":
72
+ return {"ok": True, "endpoint": self.endpoint, "status": 400}
73
+ raise
74
+
75
+
76
+ def _error_code(e: urllib.error.HTTPError) -> str:
77
+ """The `error` field of an ingest error body ({"error": code, ...}), or ""."""
78
+ try:
79
+ body = json.loads(e.read().decode("utf-8"))
80
+ except Exception:
81
+ return ""
82
+ return body.get("error", "") if isinstance(body, dict) else ""