event-bridge-client 1.0.0__tar.gz
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.
- event_bridge_client-1.0.0/.gitignore +16 -0
- event_bridge_client-1.0.0/PKG-INFO +157 -0
- event_bridge_client-1.0.0/README.md +140 -0
- event_bridge_client-1.0.0/pyproject.toml +26 -0
- event_bridge_client-1.0.0/src/event_bridge_client/__init__.py +43 -0
- event_bridge_client-1.0.0/src/event_bridge_client/_util.py +28 -0
- event_bridge_client-1.0.0/src/event_bridge_client/batcher.py +140 -0
- event_bridge_client-1.0.0/src/event_bridge_client/client.py +231 -0
- event_bridge_client-1.0.0/src/event_bridge_client/errors.py +24 -0
- event_bridge_client-1.0.0/src/event_bridge_client/hmac.py +85 -0
- event_bridge_client-1.0.0/src/event_bridge_client/middleware.py +200 -0
- event_bridge_client-1.0.0/src/event_bridge_client/nonce.py +45 -0
- event_bridge_client-1.0.0/src/event_bridge_client/options.py +35 -0
- event_bridge_client-1.0.0/src/event_bridge_client/schemas.py +109 -0
- event_bridge_client-1.0.0/src/event_bridge_client/ulid.py +24 -0
- event_bridge_client-1.0.0/tests/test_client.py +183 -0
- event_bridge_client-1.0.0/tests/test_hmac.py +58 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: event-bridge-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Lightweight client for registering apps, batching lifecycle events, and handling HMAC-signed remote commands.
|
|
5
|
+
License: UNLICENSED
|
|
6
|
+
Keywords: commands,events,hmac,webhooks
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: pydantic>=2.4
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: anyio>=4; extra == 'dev'
|
|
12
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Provides-Extra: fastapi
|
|
15
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# event-bridge-client (Python)
|
|
19
|
+
|
|
20
|
+
A lightweight client for connecting a Python application to a control backend:
|
|
21
|
+
register the app, batch and push lifecycle events, and handle HMAC-signed remote
|
|
22
|
+
commands. Wire-compatible with the Node `event-bridge-client` — same signing
|
|
23
|
+
scheme, same endpoints, same payloads.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install event-bridge-client # core (send events, verify commands)
|
|
29
|
+
pip install "event-bridge-client[fastapi]" # + FastAPI inbound adapter
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.9+.
|
|
33
|
+
|
|
34
|
+
## Quickstart (FastAPI)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from fastapi import FastAPI, Request
|
|
38
|
+
from event_bridge_client import create_client
|
|
39
|
+
|
|
40
|
+
client = create_client(
|
|
41
|
+
base_url="https://bridge.example.com",
|
|
42
|
+
api_key="...",
|
|
43
|
+
callback_url="https://api.example.com/bridge/commands",
|
|
44
|
+
callback_secret="...",
|
|
45
|
+
capabilities=["user.ban", "user.unban"],
|
|
46
|
+
env="PROD",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@client.on_command("user.ban")
|
|
50
|
+
async def _(data, ctx):
|
|
51
|
+
await ban_account(data["externalUserId"], data["reason"])
|
|
52
|
+
return {"ok": True}
|
|
53
|
+
|
|
54
|
+
app = FastAPI()
|
|
55
|
+
|
|
56
|
+
@app.post("/bridge/commands")
|
|
57
|
+
async def commands(request: Request):
|
|
58
|
+
return await client.middleware.fastapi(request)
|
|
59
|
+
|
|
60
|
+
@app.on_event("startup")
|
|
61
|
+
async def _startup():
|
|
62
|
+
await client.register()
|
|
63
|
+
|
|
64
|
+
@app.on_event("shutdown")
|
|
65
|
+
async def _shutdown():
|
|
66
|
+
await client.aclose()
|
|
67
|
+
|
|
68
|
+
# Anywhere in your app:
|
|
69
|
+
client.events.emit("user.created", {"externalUserId": "usr_123", "email": "a@b.c"})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Command handlers may be sync or async. Each receives `(data, ctx)` where `data`
|
|
73
|
+
is the raw command payload (a dict) and `ctx` carries `command_id`, `issued_at`,
|
|
74
|
+
and `issued_by`. Return `{"ok": True, "result": ...}` or `{"ok": False, "error": "..."}`.
|
|
75
|
+
|
|
76
|
+
## Managed resources
|
|
77
|
+
|
|
78
|
+
Declare an entity the backend can list / search / view / action — entirely from
|
|
79
|
+
the descriptor, with no backend-side code change. Records are never shipped to
|
|
80
|
+
the backend; it proxies `list` / `get` / `action` queries back over the same
|
|
81
|
+
signed channel.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
client.define_resource(
|
|
85
|
+
{
|
|
86
|
+
"key": "widgetUser",
|
|
87
|
+
"label": "Widget User",
|
|
88
|
+
"labelPlural": "Widget Users",
|
|
89
|
+
"titleField": "email",
|
|
90
|
+
"fields": [
|
|
91
|
+
{"key": "id", "label": "ID", "type": "string", "listVisible": False},
|
|
92
|
+
{"key": "email", "label": "Email", "type": "email", "filterable": True},
|
|
93
|
+
{"key": "plan", "label": "Plan", "type": "enum", "enumValues": ["free", "pro"]},
|
|
94
|
+
],
|
|
95
|
+
"actions": [
|
|
96
|
+
{
|
|
97
|
+
"capability": "widgetUser.ban",
|
|
98
|
+
"label": "Ban",
|
|
99
|
+
"confirm": True,
|
|
100
|
+
"destructive": True,
|
|
101
|
+
"fields": [{"name": "reason", "label": "Reason", "kind": "textarea", "required": True}],
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
# `query` is a ResourceListQuery: query.page, query.page_size, query.q, ...
|
|
106
|
+
list=lambda query: {"records": db.search(query.q, query.page, query.page_size), "total": db.count()},
|
|
107
|
+
get=lambda record_id: db.find(record_id), # optional
|
|
108
|
+
action=lambda inp: ban(inp["recordId"], inp["params"]["reason"]), # optional
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Descriptor keys accept either `snake_case` or `camelCase`; they're sent to the
|
|
113
|
+
backend as `camelCase`. Field `type` is one of `string`, `number`, `boolean`,
|
|
114
|
+
`date`, `datetime`, `enum`, `currency`, `badge`, `email`, `url`, `json`.
|
|
115
|
+
|
|
116
|
+
## Options
|
|
117
|
+
|
|
118
|
+
`create_client(...)` keyword arguments:
|
|
119
|
+
|
|
120
|
+
| Option | Default | Notes |
|
|
121
|
+
|---------------------|-----------|--------------------------------------------------------|
|
|
122
|
+
| `base_url` | required | Control backend base URL |
|
|
123
|
+
| `api_key` | required | API key minted by the backend admin |
|
|
124
|
+
| `callback_url` | required | HTTPS URL the backend POSTs commands to |
|
|
125
|
+
| `callback_secret` | required | HMAC shared secret minted alongside the API key |
|
|
126
|
+
| `capabilities` | `()` | Strings matching command types, e.g. `user.ban` |
|
|
127
|
+
| `env` | `"PROD"` | `PROD` / `STAGING` / `DEV` |
|
|
128
|
+
| `enabled` | `True` | If `False`, all methods are no-ops (staged rollout) |
|
|
129
|
+
| `batch_interval_ms` | `1500` | Event batcher flush interval |
|
|
130
|
+
| `batch_max_size` | `100` | Force-flush when this many events are queued |
|
|
131
|
+
| `max_buffer_size` | `10000` | Hard cap on buffered events; oldest dropped past it |
|
|
132
|
+
| `max_retries` | `6` | Exponential-backoff retries for event batch POSTs |
|
|
133
|
+
| `nonce_store` | in-memory | Replay store; supply a shared one for multi-instance |
|
|
134
|
+
|
|
135
|
+
## Replay protection across instances
|
|
136
|
+
|
|
137
|
+
The default replay cache is **in-process** — it only protects a single instance.
|
|
138
|
+
If you run more than one instance behind a load balancer, supply a shared
|
|
139
|
+
`nonce_store` (e.g. Redis) so a captured command can't be replayed against
|
|
140
|
+
another instance inside the 300-second signature window:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
class RedisNonceStore:
|
|
144
|
+
def __init__(self, redis): self.r = redis
|
|
145
|
+
async def has(self, nonce: str) -> bool:
|
|
146
|
+
return await self.r.exists(f"bridge:nonce:{nonce}") > 0
|
|
147
|
+
async def add(self, nonce: str, ttl_ms: int) -> None:
|
|
148
|
+
await self.r.set(f"bridge:nonce:{nonce}", "1", px=ttl_ms, nx=True)
|
|
149
|
+
|
|
150
|
+
client = create_client(..., nonce_store=RedisNonceStore(redis))
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Send-only usage (no FastAPI)
|
|
154
|
+
|
|
155
|
+
If the app only emits events and never receives commands, you don't need
|
|
156
|
+
FastAPI — `pip install event-bridge-client` and use `register()` /
|
|
157
|
+
`events.emit()` / `aclose()`. The middleware is only needed to receive commands.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# event-bridge-client (Python)
|
|
2
|
+
|
|
3
|
+
A lightweight client for connecting a Python application to a control backend:
|
|
4
|
+
register the app, batch and push lifecycle events, and handle HMAC-signed remote
|
|
5
|
+
commands. Wire-compatible with the Node `event-bridge-client` — same signing
|
|
6
|
+
scheme, same endpoints, same payloads.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install event-bridge-client # core (send events, verify commands)
|
|
12
|
+
pip install "event-bridge-client[fastapi]" # + FastAPI inbound adapter
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Python 3.9+.
|
|
16
|
+
|
|
17
|
+
## Quickstart (FastAPI)
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from fastapi import FastAPI, Request
|
|
21
|
+
from event_bridge_client import create_client
|
|
22
|
+
|
|
23
|
+
client = create_client(
|
|
24
|
+
base_url="https://bridge.example.com",
|
|
25
|
+
api_key="...",
|
|
26
|
+
callback_url="https://api.example.com/bridge/commands",
|
|
27
|
+
callback_secret="...",
|
|
28
|
+
capabilities=["user.ban", "user.unban"],
|
|
29
|
+
env="PROD",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@client.on_command("user.ban")
|
|
33
|
+
async def _(data, ctx):
|
|
34
|
+
await ban_account(data["externalUserId"], data["reason"])
|
|
35
|
+
return {"ok": True}
|
|
36
|
+
|
|
37
|
+
app = FastAPI()
|
|
38
|
+
|
|
39
|
+
@app.post("/bridge/commands")
|
|
40
|
+
async def commands(request: Request):
|
|
41
|
+
return await client.middleware.fastapi(request)
|
|
42
|
+
|
|
43
|
+
@app.on_event("startup")
|
|
44
|
+
async def _startup():
|
|
45
|
+
await client.register()
|
|
46
|
+
|
|
47
|
+
@app.on_event("shutdown")
|
|
48
|
+
async def _shutdown():
|
|
49
|
+
await client.aclose()
|
|
50
|
+
|
|
51
|
+
# Anywhere in your app:
|
|
52
|
+
client.events.emit("user.created", {"externalUserId": "usr_123", "email": "a@b.c"})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Command handlers may be sync or async. Each receives `(data, ctx)` where `data`
|
|
56
|
+
is the raw command payload (a dict) and `ctx` carries `command_id`, `issued_at`,
|
|
57
|
+
and `issued_by`. Return `{"ok": True, "result": ...}` or `{"ok": False, "error": "..."}`.
|
|
58
|
+
|
|
59
|
+
## Managed resources
|
|
60
|
+
|
|
61
|
+
Declare an entity the backend can list / search / view / action — entirely from
|
|
62
|
+
the descriptor, with no backend-side code change. Records are never shipped to
|
|
63
|
+
the backend; it proxies `list` / `get` / `action` queries back over the same
|
|
64
|
+
signed channel.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
client.define_resource(
|
|
68
|
+
{
|
|
69
|
+
"key": "widgetUser",
|
|
70
|
+
"label": "Widget User",
|
|
71
|
+
"labelPlural": "Widget Users",
|
|
72
|
+
"titleField": "email",
|
|
73
|
+
"fields": [
|
|
74
|
+
{"key": "id", "label": "ID", "type": "string", "listVisible": False},
|
|
75
|
+
{"key": "email", "label": "Email", "type": "email", "filterable": True},
|
|
76
|
+
{"key": "plan", "label": "Plan", "type": "enum", "enumValues": ["free", "pro"]},
|
|
77
|
+
],
|
|
78
|
+
"actions": [
|
|
79
|
+
{
|
|
80
|
+
"capability": "widgetUser.ban",
|
|
81
|
+
"label": "Ban",
|
|
82
|
+
"confirm": True,
|
|
83
|
+
"destructive": True,
|
|
84
|
+
"fields": [{"name": "reason", "label": "Reason", "kind": "textarea", "required": True}],
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
# `query` is a ResourceListQuery: query.page, query.page_size, query.q, ...
|
|
89
|
+
list=lambda query: {"records": db.search(query.q, query.page, query.page_size), "total": db.count()},
|
|
90
|
+
get=lambda record_id: db.find(record_id), # optional
|
|
91
|
+
action=lambda inp: ban(inp["recordId"], inp["params"]["reason"]), # optional
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Descriptor keys accept either `snake_case` or `camelCase`; they're sent to the
|
|
96
|
+
backend as `camelCase`. Field `type` is one of `string`, `number`, `boolean`,
|
|
97
|
+
`date`, `datetime`, `enum`, `currency`, `badge`, `email`, `url`, `json`.
|
|
98
|
+
|
|
99
|
+
## Options
|
|
100
|
+
|
|
101
|
+
`create_client(...)` keyword arguments:
|
|
102
|
+
|
|
103
|
+
| Option | Default | Notes |
|
|
104
|
+
|---------------------|-----------|--------------------------------------------------------|
|
|
105
|
+
| `base_url` | required | Control backend base URL |
|
|
106
|
+
| `api_key` | required | API key minted by the backend admin |
|
|
107
|
+
| `callback_url` | required | HTTPS URL the backend POSTs commands to |
|
|
108
|
+
| `callback_secret` | required | HMAC shared secret minted alongside the API key |
|
|
109
|
+
| `capabilities` | `()` | Strings matching command types, e.g. `user.ban` |
|
|
110
|
+
| `env` | `"PROD"` | `PROD` / `STAGING` / `DEV` |
|
|
111
|
+
| `enabled` | `True` | If `False`, all methods are no-ops (staged rollout) |
|
|
112
|
+
| `batch_interval_ms` | `1500` | Event batcher flush interval |
|
|
113
|
+
| `batch_max_size` | `100` | Force-flush when this many events are queued |
|
|
114
|
+
| `max_buffer_size` | `10000` | Hard cap on buffered events; oldest dropped past it |
|
|
115
|
+
| `max_retries` | `6` | Exponential-backoff retries for event batch POSTs |
|
|
116
|
+
| `nonce_store` | in-memory | Replay store; supply a shared one for multi-instance |
|
|
117
|
+
|
|
118
|
+
## Replay protection across instances
|
|
119
|
+
|
|
120
|
+
The default replay cache is **in-process** — it only protects a single instance.
|
|
121
|
+
If you run more than one instance behind a load balancer, supply a shared
|
|
122
|
+
`nonce_store` (e.g. Redis) so a captured command can't be replayed against
|
|
123
|
+
another instance inside the 300-second signature window:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
class RedisNonceStore:
|
|
127
|
+
def __init__(self, redis): self.r = redis
|
|
128
|
+
async def has(self, nonce: str) -> bool:
|
|
129
|
+
return await self.r.exists(f"bridge:nonce:{nonce}") > 0
|
|
130
|
+
async def add(self, nonce: str, ttl_ms: int) -> None:
|
|
131
|
+
await self.r.set(f"bridge:nonce:{nonce}", "1", px=ttl_ms, nx=True)
|
|
132
|
+
|
|
133
|
+
client = create_client(..., nonce_store=RedisNonceStore(redis))
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Send-only usage (no FastAPI)
|
|
137
|
+
|
|
138
|
+
If the app only emits events and never receives commands, you don't need
|
|
139
|
+
FastAPI — `pip install event-bridge-client` and use `register()` /
|
|
140
|
+
`events.emit()` / `aclose()`. The middleware is only needed to receive commands.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "event-bridge-client"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Lightweight client for registering apps, batching lifecycle events, and handling HMAC-signed remote commands."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "UNLICENSED" }
|
|
12
|
+
keywords = ["events", "hmac", "webhooks", "commands"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
"pydantic>=2.4",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
fastapi = ["fastapi>=0.110"]
|
|
20
|
+
dev = ["pytest>=8", "fastapi>=0.110", "anyio>=4"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/event_bridge_client"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .batcher import SDK_VERSION
|
|
4
|
+
from .client import EventBridgeClient, create_client
|
|
5
|
+
from .errors import HandlerNotRegisteredError, ManagementError, SignatureError
|
|
6
|
+
from .hmac import HMAC_HEADERS, HMAC_WINDOW_SECONDS, SignedHeaders, sign, verify
|
|
7
|
+
from .nonce import NonceLRU, NonceStore
|
|
8
|
+
from .options import ClientOptions, CommandCtx
|
|
9
|
+
from .schemas import (
|
|
10
|
+
COMMAND_TYPES,
|
|
11
|
+
EVENT_TYPES,
|
|
12
|
+
ResourceAction,
|
|
13
|
+
ResourceDescriptor,
|
|
14
|
+
ResourceField,
|
|
15
|
+
ResourceListQuery,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = SDK_VERSION
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"create_client",
|
|
22
|
+
"EventBridgeClient",
|
|
23
|
+
"ClientOptions",
|
|
24
|
+
"CommandCtx",
|
|
25
|
+
"sign",
|
|
26
|
+
"verify",
|
|
27
|
+
"SignedHeaders",
|
|
28
|
+
"HMAC_HEADERS",
|
|
29
|
+
"HMAC_WINDOW_SECONDS",
|
|
30
|
+
"NonceStore",
|
|
31
|
+
"NonceLRU",
|
|
32
|
+
"ManagementError",
|
|
33
|
+
"SignatureError",
|
|
34
|
+
"HandlerNotRegisteredError",
|
|
35
|
+
"ResourceDescriptor",
|
|
36
|
+
"ResourceField",
|
|
37
|
+
"ResourceAction",
|
|
38
|
+
"ResourceListQuery",
|
|
39
|
+
"EVENT_TYPES",
|
|
40
|
+
"COMMAND_TYPES",
|
|
41
|
+
"SDK_VERSION",
|
|
42
|
+
"__version__",
|
|
43
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_default_logger = logging.getLogger("event_bridge_client")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def now_iso() -> str:
|
|
12
|
+
"""ISO-8601 timestamp with millisecond precision and a ``Z`` suffix,
|
|
13
|
+
byte-identical to JavaScript's ``new Date().toISOString()`` so the backend's
|
|
14
|
+
datetime validation accepts it regardless of which SDK produced the event."""
|
|
15
|
+
dt = datetime.now(timezone.utc)
|
|
16
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def maybe_await(value: Any) -> Any:
|
|
20
|
+
"""Await ``value`` if it is awaitable, otherwise return it as-is. Lets
|
|
21
|
+
handlers be either sync or async."""
|
|
22
|
+
if inspect.isawaitable(value):
|
|
23
|
+
return await value
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_logger(logger: logging.Logger | None) -> logging.Logger:
|
|
28
|
+
return logger or _default_logger
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._util import get_logger, now_iso
|
|
10
|
+
from .options import ClientOptions
|
|
11
|
+
from .ulid import ulid
|
|
12
|
+
|
|
13
|
+
# Keep in sync with pyproject.toml "version" and the Node SDK.
|
|
14
|
+
SDK_VERSION = "1.0.0"
|
|
15
|
+
DEFAULT_MAX_BUFFER = 10_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EventBatcher:
|
|
19
|
+
"""Buffers events and flushes them to ``/sdk/events`` on an interval or when
|
|
20
|
+
the buffer fills. Mirrors the Node batcher: bounded buffer (drop-oldest),
|
|
21
|
+
single-flight flush, and exponential backoff on failure."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, opts: ClientOptions, http: httpx.AsyncClient) -> None:
|
|
24
|
+
self._o = opts
|
|
25
|
+
self._http = http
|
|
26
|
+
self._buffer: List[Dict[str, Any]] = []
|
|
27
|
+
self._lock = asyncio.Lock()
|
|
28
|
+
self._wake = asyncio.Event()
|
|
29
|
+
self._task: "asyncio.Task[None] | None" = None
|
|
30
|
+
self._stopped = False
|
|
31
|
+
self._retry = 0
|
|
32
|
+
self._next_send_at = 0.0
|
|
33
|
+
|
|
34
|
+
def start(self) -> None:
|
|
35
|
+
if self._task is not None:
|
|
36
|
+
return
|
|
37
|
+
self._stopped = False
|
|
38
|
+
self._task = asyncio.create_task(self._run())
|
|
39
|
+
|
|
40
|
+
def emit(self, event_type: str, data: Any) -> None:
|
|
41
|
+
if not self._o.enabled:
|
|
42
|
+
return
|
|
43
|
+
self._buffer.append(
|
|
44
|
+
{"id": ulid(), "type": event_type, "data": data, "occurredAt": now_iso()}
|
|
45
|
+
)
|
|
46
|
+
self._cap_buffer()
|
|
47
|
+
if len(self._buffer) >= self._o.batch_max_size:
|
|
48
|
+
self._wake.set()
|
|
49
|
+
|
|
50
|
+
def _cap_buffer(self) -> None:
|
|
51
|
+
max_ = self._o.max_buffer_size or DEFAULT_MAX_BUFFER
|
|
52
|
+
if len(self._buffer) > max_:
|
|
53
|
+
dropped = len(self._buffer) - max_
|
|
54
|
+
del self._buffer[:dropped]
|
|
55
|
+
get_logger(self._o.logger).warning(
|
|
56
|
+
"[event-bridge] event buffer exceeded %d; dropped %d oldest event(s)",
|
|
57
|
+
max_,
|
|
58
|
+
dropped,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def _run(self) -> None:
|
|
62
|
+
interval = self._o.batch_interval_ms / 1000.0
|
|
63
|
+
while not self._stopped:
|
|
64
|
+
try:
|
|
65
|
+
await asyncio.wait_for(self._wake.wait(), timeout=interval)
|
|
66
|
+
except asyncio.TimeoutError:
|
|
67
|
+
pass
|
|
68
|
+
self._wake.clear()
|
|
69
|
+
await self.flush()
|
|
70
|
+
|
|
71
|
+
async def flush(self) -> None:
|
|
72
|
+
if not self._o.enabled:
|
|
73
|
+
return
|
|
74
|
+
async with self._lock:
|
|
75
|
+
if not self._buffer:
|
|
76
|
+
return
|
|
77
|
+
# Respect the backoff window set by a previous failure.
|
|
78
|
+
if time.monotonic() < self._next_send_at:
|
|
79
|
+
return
|
|
80
|
+
batch = self._buffer[: self._o.batch_max_size]
|
|
81
|
+
del self._buffer[: len(batch)]
|
|
82
|
+
payload = {
|
|
83
|
+
"events": [
|
|
84
|
+
{
|
|
85
|
+
"id": e["id"],
|
|
86
|
+
"occurredAt": e["occurredAt"],
|
|
87
|
+
"sdkVersion": SDK_VERSION,
|
|
88
|
+
"type": e["type"],
|
|
89
|
+
"data": e["data"],
|
|
90
|
+
}
|
|
91
|
+
for e in batch
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
await self._send(batch, payload)
|
|
95
|
+
|
|
96
|
+
async def _send(self, batch: List[Dict[str, Any]], payload: Dict[str, Any]) -> None:
|
|
97
|
+
try:
|
|
98
|
+
resp = await self._http.post(
|
|
99
|
+
f"{self._o.base_url}/sdk/events",
|
|
100
|
+
headers={"content-type": "application/json", "x-api-key": self._o.api_key},
|
|
101
|
+
json=payload,
|
|
102
|
+
)
|
|
103
|
+
if resp.status_code >= 400:
|
|
104
|
+
raise RuntimeError(f"events POST {resp.status_code}")
|
|
105
|
+
self._retry = 0
|
|
106
|
+
self._next_send_at = 0.0
|
|
107
|
+
except Exception as err: # noqa: BLE001 — network/HTTP failures are expected
|
|
108
|
+
if self._retry >= self._o.max_retries:
|
|
109
|
+
get_logger(self._o.logger).error(
|
|
110
|
+
"[event-bridge] events batch dropped after retries: %s", err
|
|
111
|
+
)
|
|
112
|
+
self._retry = 0
|
|
113
|
+
self._next_send_at = 0.0
|
|
114
|
+
return
|
|
115
|
+
self._retry += 1
|
|
116
|
+
# Re-queue at the head and back off; the interval loop retries.
|
|
117
|
+
self._buffer[0:0] = batch
|
|
118
|
+
self._cap_buffer()
|
|
119
|
+
delay = min(60.0, 1.0 * (2 ** self._retry))
|
|
120
|
+
self._next_send_at = time.monotonic() + delay
|
|
121
|
+
get_logger(self._o.logger).warning(
|
|
122
|
+
"[event-bridge] events retry in %.0fs (attempt %d)", delay, self._retry
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def drain(self) -> None:
|
|
126
|
+
"""Stop the timer and make a bounded best-effort attempt to flush what's
|
|
127
|
+
left (used on shutdown). Won't spin forever if the backend is down."""
|
|
128
|
+
self._stopped = True
|
|
129
|
+
if self._task is not None:
|
|
130
|
+
self._wake.set()
|
|
131
|
+
await asyncio.gather(self._task, return_exceptions=True)
|
|
132
|
+
self._task = None
|
|
133
|
+
for _ in range(self._o.max_retries + 1):
|
|
134
|
+
if not self._buffer:
|
|
135
|
+
break
|
|
136
|
+
self._next_send_at = 0.0 # ignore backoff on shutdown
|
|
137
|
+
before = len(self._buffer)
|
|
138
|
+
await self.flush()
|
|
139
|
+
if self._buffer and len(self._buffer) >= before:
|
|
140
|
+
break # not making progress — give up rather than hang
|