fastapi-microservices-platform-sdk 0.1.1__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.
- fastapi_microservices_platform_sdk-0.1.1.dist-info/LICENSE +201 -0
- fastapi_microservices_platform_sdk-0.1.1.dist-info/METADATA +277 -0
- fastapi_microservices_platform_sdk-0.1.1.dist-info/RECORD +11 -0
- fastapi_microservices_platform_sdk-0.1.1.dist-info/WHEEL +5 -0
- fastapi_microservices_platform_sdk-0.1.1.dist-info/entry_points.txt +2 -0
- fastapi_microservices_platform_sdk-0.1.1.dist-info/top_level.txt +1 -0
- webhook_platform_sdk/__init__.py +39 -0
- webhook_platform_sdk/cli.py +371 -0
- webhook_platform_sdk/outbox.py +281 -0
- webhook_platform_sdk/producer.py +391 -0
- webhook_platform_sdk/receiver.py +281 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""`webhookctl` control-plane and test-event CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from getpass import getpass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from webhook_platform_sdk.producer import (
|
|
16
|
+
AuthClient,
|
|
17
|
+
ManagementClient,
|
|
18
|
+
Producer,
|
|
19
|
+
WebhookPlatformError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_project_id(parser: argparse.ArgumentParser) -> None:
|
|
24
|
+
parser.add_argument("--project", required=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(prog="webhookctl")
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--base-url",
|
|
31
|
+
default=os.getenv("WEBHOOK_PLATFORM_URL", "http://localhost:8000"),
|
|
32
|
+
)
|
|
33
|
+
commands = parser.add_subparsers(dest="resource", required=True)
|
|
34
|
+
|
|
35
|
+
auth = commands.add_parser("auth")
|
|
36
|
+
auth_commands = auth.add_subparsers(dest="action", required=True)
|
|
37
|
+
auth_register = auth_commands.add_parser("register")
|
|
38
|
+
auth_register.add_argument("--email", required=True)
|
|
39
|
+
auth_register.add_argument("--name", required=True)
|
|
40
|
+
auth_login = auth_commands.add_parser("login")
|
|
41
|
+
auth_login.add_argument("--email", required=True)
|
|
42
|
+
|
|
43
|
+
organizations = commands.add_parser("organizations")
|
|
44
|
+
organization_commands = organizations.add_subparsers(
|
|
45
|
+
dest="action", required=True
|
|
46
|
+
)
|
|
47
|
+
organization_commands.add_parser("list")
|
|
48
|
+
organization_create = organization_commands.add_parser("create")
|
|
49
|
+
organization_create.add_argument("--name", required=True)
|
|
50
|
+
|
|
51
|
+
projects = commands.add_parser("projects")
|
|
52
|
+
project_commands = projects.add_subparsers(dest="action", required=True)
|
|
53
|
+
project_list = project_commands.add_parser("list")
|
|
54
|
+
project_list.add_argument("--organization", required=True)
|
|
55
|
+
project_create = project_commands.add_parser("create")
|
|
56
|
+
project_create.add_argument("--organization", required=True)
|
|
57
|
+
project_create.add_argument("--name", required=True)
|
|
58
|
+
|
|
59
|
+
api_keys = commands.add_parser("api-keys")
|
|
60
|
+
api_key_commands = api_keys.add_subparsers(dest="action", required=True)
|
|
61
|
+
api_key_list = api_key_commands.add_parser("list")
|
|
62
|
+
_add_project_id(api_key_list)
|
|
63
|
+
api_key_create = api_key_commands.add_parser("create")
|
|
64
|
+
_add_project_id(api_key_create)
|
|
65
|
+
api_key_create.add_argument("--name", required=True)
|
|
66
|
+
api_key_create.add_argument("--expires-in-days", type=int)
|
|
67
|
+
api_key_revoke = api_key_commands.add_parser("revoke")
|
|
68
|
+
_add_project_id(api_key_revoke)
|
|
69
|
+
api_key_revoke.add_argument("--api-key", required=True)
|
|
70
|
+
|
|
71
|
+
endpoints = commands.add_parser("endpoints")
|
|
72
|
+
endpoint_commands = endpoints.add_subparsers(dest="action", required=True)
|
|
73
|
+
endpoint_list = endpoint_commands.add_parser("list")
|
|
74
|
+
_add_project_id(endpoint_list)
|
|
75
|
+
endpoint_create = endpoint_commands.add_parser("create")
|
|
76
|
+
_add_project_id(endpoint_create)
|
|
77
|
+
endpoint_create.add_argument("--url", required=True)
|
|
78
|
+
endpoint_create.add_argument("--description")
|
|
79
|
+
endpoint_create.add_argument(
|
|
80
|
+
"--event-type",
|
|
81
|
+
action="append",
|
|
82
|
+
dest="event_types",
|
|
83
|
+
metavar="TYPE",
|
|
84
|
+
help=(
|
|
85
|
+
"subscribe only to this event type; repeatable; supports a "
|
|
86
|
+
"trailing 'prefix.*' wildcard; omit to receive every event"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
endpoint_create.add_argument(
|
|
90
|
+
"--signature-scheme",
|
|
91
|
+
choices=("legacy", "standard"),
|
|
92
|
+
dest="signature_scheme",
|
|
93
|
+
help="wire signature scheme; 'standard' emits Standard Webhooks",
|
|
94
|
+
)
|
|
95
|
+
endpoint_update = endpoint_commands.add_parser("update")
|
|
96
|
+
_add_project_id(endpoint_update)
|
|
97
|
+
endpoint_update.add_argument("--endpoint", required=True)
|
|
98
|
+
endpoint_update.add_argument("--url")
|
|
99
|
+
endpoint_update.add_argument("--description")
|
|
100
|
+
endpoint_activation = endpoint_update.add_mutually_exclusive_group()
|
|
101
|
+
endpoint_activation.add_argument(
|
|
102
|
+
"--activate", action="store_true", help="reactivate the endpoint"
|
|
103
|
+
)
|
|
104
|
+
endpoint_activation.add_argument(
|
|
105
|
+
"--deactivate", action="store_true", help="deactivate the endpoint"
|
|
106
|
+
)
|
|
107
|
+
endpoint_filter = endpoint_update.add_mutually_exclusive_group()
|
|
108
|
+
endpoint_filter.add_argument(
|
|
109
|
+
"--event-type",
|
|
110
|
+
action="append",
|
|
111
|
+
dest="event_types",
|
|
112
|
+
metavar="TYPE",
|
|
113
|
+
help="replace the subscription list with these types; repeatable",
|
|
114
|
+
)
|
|
115
|
+
endpoint_filter.add_argument(
|
|
116
|
+
"--all-events",
|
|
117
|
+
action="store_true",
|
|
118
|
+
help="clear the subscription filter and receive every event",
|
|
119
|
+
)
|
|
120
|
+
endpoint_update.add_argument(
|
|
121
|
+
"--signature-scheme",
|
|
122
|
+
choices=("legacy", "standard"),
|
|
123
|
+
dest="signature_scheme",
|
|
124
|
+
help=(
|
|
125
|
+
"change the wire signature scheme; switching to 'standard' "
|
|
126
|
+
"prints a one-time signing secret"
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
events = commands.add_parser("events")
|
|
131
|
+
event_commands = events.add_subparsers(dest="action", required=True)
|
|
132
|
+
event_send = event_commands.add_parser("send")
|
|
133
|
+
event_send.add_argument("--type", required=True)
|
|
134
|
+
event_send.add_argument("--idempotency-key", required=True)
|
|
135
|
+
event_send.add_argument("--payload-file", required=True)
|
|
136
|
+
event_send.add_argument(
|
|
137
|
+
"--envelope-mode", choices=("native", "cloudevents"), default="native"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
deliveries = commands.add_parser("deliveries")
|
|
141
|
+
delivery_commands = deliveries.add_subparsers(dest="action", required=True)
|
|
142
|
+
delivery_list = delivery_commands.add_parser("list")
|
|
143
|
+
_add_project_id(delivery_list)
|
|
144
|
+
delivery_list.add_argument("--offset", type=int, default=0)
|
|
145
|
+
delivery_list.add_argument("--limit", type=int, default=50)
|
|
146
|
+
for action in ("get", "attempts"):
|
|
147
|
+
command = delivery_commands.add_parser(action)
|
|
148
|
+
_add_project_id(command)
|
|
149
|
+
command.add_argument("--delivery", required=True)
|
|
150
|
+
|
|
151
|
+
replay = commands.add_parser("replay")
|
|
152
|
+
replay_commands = replay.add_subparsers(dest="action", required=True)
|
|
153
|
+
replay_one = replay_commands.add_parser("one")
|
|
154
|
+
_add_project_id(replay_one)
|
|
155
|
+
replay_one.add_argument("--delivery", required=True)
|
|
156
|
+
replay_bulk = replay_commands.add_parser("bulk")
|
|
157
|
+
_add_project_id(replay_bulk)
|
|
158
|
+
replay_bulk.add_argument("--delivery", action="append", required=True)
|
|
159
|
+
replay_bulk.add_argument("--idempotency-key", required=True)
|
|
160
|
+
return parser
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _credentials_path() -> Path:
|
|
164
|
+
configured = os.getenv("WEBHOOK_PLATFORM_CREDENTIALS")
|
|
165
|
+
if configured:
|
|
166
|
+
return Path(configured).expanduser()
|
|
167
|
+
return Path.home() / ".config/webhook-platform/credentials.json"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _save_credentials(base_url: str, token: str) -> Path:
|
|
171
|
+
path = _credentials_path()
|
|
172
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
173
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
174
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
175
|
+
)
|
|
176
|
+
temporary = Path(temporary_name)
|
|
177
|
+
try:
|
|
178
|
+
os.chmod(temporary, 0o600)
|
|
179
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
180
|
+
descriptor = -1
|
|
181
|
+
json.dump(
|
|
182
|
+
{"base_url": base_url.rstrip("/"), "token": token},
|
|
183
|
+
handle,
|
|
184
|
+
)
|
|
185
|
+
handle.flush()
|
|
186
|
+
os.fsync(handle.fileno())
|
|
187
|
+
os.replace(temporary, path)
|
|
188
|
+
os.chmod(path, 0o600)
|
|
189
|
+
finally:
|
|
190
|
+
if descriptor >= 0:
|
|
191
|
+
os.close(descriptor)
|
|
192
|
+
temporary.unlink(missing_ok=True)
|
|
193
|
+
return path
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _stored_token(base_url: str) -> str | None:
|
|
197
|
+
path = _credentials_path()
|
|
198
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
199
|
+
try:
|
|
200
|
+
descriptor = os.open(path, flags)
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
return None
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
raise ValueError("credential file cannot be read safely") from exc
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
metadata = os.fstat(descriptor)
|
|
208
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
209
|
+
raise ValueError("credential file must be a regular file")
|
|
210
|
+
if stat.S_IMODE(metadata.st_mode) != 0o600:
|
|
211
|
+
raise ValueError("credential file permissions must be 0600")
|
|
212
|
+
with os.fdopen(descriptor, encoding="utf-8") as handle:
|
|
213
|
+
descriptor = -1
|
|
214
|
+
document = json.load(handle)
|
|
215
|
+
finally:
|
|
216
|
+
if descriptor >= 0:
|
|
217
|
+
os.close(descriptor)
|
|
218
|
+
|
|
219
|
+
if not isinstance(document, dict):
|
|
220
|
+
raise ValueError("credential file is invalid")
|
|
221
|
+
stored_url = document.get("base_url")
|
|
222
|
+
token = document.get("token")
|
|
223
|
+
if stored_url != base_url.rstrip("/"):
|
|
224
|
+
raise ValueError("credential file belongs to a different base URL")
|
|
225
|
+
if not isinstance(token, str) or not token:
|
|
226
|
+
raise ValueError("credential file is invalid")
|
|
227
|
+
return token
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _management(base_url: str) -> ManagementClient:
|
|
231
|
+
token = os.getenv("WEBHOOK_PLATFORM_TOKEN") or _stored_token(base_url)
|
|
232
|
+
if not token:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
"run `webhookctl auth login` or set WEBHOOK_PLATFORM_TOKEN"
|
|
235
|
+
)
|
|
236
|
+
return ManagementClient(base_url, token)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _producer(base_url: str) -> Producer:
|
|
240
|
+
api_key = os.getenv("WEBHOOK_PLATFORM_API_KEY")
|
|
241
|
+
if not api_key:
|
|
242
|
+
raise ValueError("WEBHOOK_PLATFORM_API_KEY is required")
|
|
243
|
+
return Producer(base_url, api_key)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _payload(path: str) -> object:
|
|
247
|
+
if path == "-":
|
|
248
|
+
return json.load(sys.stdin)
|
|
249
|
+
with Path(path).open(encoding="utf-8") as handle:
|
|
250
|
+
return json.load(handle)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _auth_command(args: argparse.Namespace) -> dict[str, Any]:
|
|
254
|
+
password = getpass("Password: ")
|
|
255
|
+
if args.action == "register":
|
|
256
|
+
confirmation = getpass("Confirm password: ")
|
|
257
|
+
if password != confirmation:
|
|
258
|
+
raise ValueError("passwords do not match")
|
|
259
|
+
with AuthClient(args.base_url) as client:
|
|
260
|
+
return client.register(args.email, args.name, password)
|
|
261
|
+
|
|
262
|
+
with AuthClient(args.base_url) as client:
|
|
263
|
+
token = client.login(args.email, password)
|
|
264
|
+
path = _save_credentials(args.base_url, token)
|
|
265
|
+
return {"status": "stored", "credential_file": str(path)}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _management_command(args: argparse.Namespace) -> Any:
|
|
269
|
+
with _management(args.base_url) as client:
|
|
270
|
+
if args.resource == "organizations":
|
|
271
|
+
if args.action == "list":
|
|
272
|
+
return client.list_organizations()
|
|
273
|
+
return client.create_organization(args.name)
|
|
274
|
+
if args.resource == "projects":
|
|
275
|
+
if args.action == "list":
|
|
276
|
+
return client.list_projects(args.organization)
|
|
277
|
+
return client.create_project(args.organization, args.name)
|
|
278
|
+
if args.resource == "api-keys":
|
|
279
|
+
if args.action == "list":
|
|
280
|
+
return client.list_api_keys(args.project)
|
|
281
|
+
if args.action == "create":
|
|
282
|
+
return client.create_api_key(
|
|
283
|
+
args.project,
|
|
284
|
+
args.name,
|
|
285
|
+
expires_in_days=args.expires_in_days,
|
|
286
|
+
)
|
|
287
|
+
return client.revoke_api_key(args.project, args.api_key)
|
|
288
|
+
if args.resource == "endpoints":
|
|
289
|
+
if args.action == "list":
|
|
290
|
+
return client.list_endpoints(args.project)
|
|
291
|
+
if args.action == "create":
|
|
292
|
+
return client.create_endpoint(
|
|
293
|
+
args.project,
|
|
294
|
+
args.url,
|
|
295
|
+
args.description,
|
|
296
|
+
event_types=args.event_types,
|
|
297
|
+
signature_scheme=args.signature_scheme,
|
|
298
|
+
)
|
|
299
|
+
changes: dict[str, Any] = {}
|
|
300
|
+
if args.url is not None:
|
|
301
|
+
changes["url"] = args.url
|
|
302
|
+
if args.description is not None:
|
|
303
|
+
changes["description"] = args.description
|
|
304
|
+
if args.activate:
|
|
305
|
+
changes["is_active"] = True
|
|
306
|
+
if args.deactivate:
|
|
307
|
+
changes["is_active"] = False
|
|
308
|
+
if args.all_events:
|
|
309
|
+
changes["event_types"] = None
|
|
310
|
+
elif args.event_types is not None:
|
|
311
|
+
changes["event_types"] = args.event_types
|
|
312
|
+
if args.signature_scheme is not None:
|
|
313
|
+
changes["signature_scheme"] = args.signature_scheme
|
|
314
|
+
if not changes:
|
|
315
|
+
raise ValueError(
|
|
316
|
+
"endpoints update requires at least one change"
|
|
317
|
+
)
|
|
318
|
+
return client.update_endpoint(
|
|
319
|
+
args.project, args.endpoint, **changes
|
|
320
|
+
)
|
|
321
|
+
if args.resource == "deliveries":
|
|
322
|
+
if args.action == "list":
|
|
323
|
+
return client.list_deliveries(
|
|
324
|
+
args.project, offset=args.offset, limit=args.limit
|
|
325
|
+
)
|
|
326
|
+
delivery = client.get_delivery(args.project, args.delivery)
|
|
327
|
+
if args.action == "attempts":
|
|
328
|
+
return delivery.get("attempts", [])
|
|
329
|
+
return delivery
|
|
330
|
+
if args.resource == "replay":
|
|
331
|
+
if args.action == "one":
|
|
332
|
+
return client.replay_delivery(args.project, args.delivery)
|
|
333
|
+
return client.replay_deliveries(
|
|
334
|
+
args.project, args.delivery, args.idempotency_key
|
|
335
|
+
)
|
|
336
|
+
raise ValueError("unsupported command")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _run(args: argparse.Namespace) -> Any:
|
|
340
|
+
if args.resource == "auth":
|
|
341
|
+
return _auth_command(args)
|
|
342
|
+
if args.resource == "events":
|
|
343
|
+
with _producer(args.base_url) as producer:
|
|
344
|
+
return producer.send_event(
|
|
345
|
+
args.type,
|
|
346
|
+
_payload(args.payload_file),
|
|
347
|
+
args.idempotency_key,
|
|
348
|
+
envelope_mode=args.envelope_mode,
|
|
349
|
+
)
|
|
350
|
+
return _management_command(args)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def main(argv: list[str] | None = None) -> int:
|
|
354
|
+
parser = _parser()
|
|
355
|
+
args = parser.parse_args(argv)
|
|
356
|
+
try:
|
|
357
|
+
result = _run(args)
|
|
358
|
+
except (
|
|
359
|
+
WebhookPlatformError,
|
|
360
|
+
ValueError,
|
|
361
|
+
OSError,
|
|
362
|
+
json.JSONDecodeError,
|
|
363
|
+
) as exc:
|
|
364
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
365
|
+
return 2
|
|
366
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Optional SQLAlchemy transactional outbox with at-least-once relay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from random import SystemRandom
|
|
9
|
+
from typing import Any, Literal, Protocol
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import (
|
|
13
|
+
JSON,
|
|
14
|
+
Column,
|
|
15
|
+
DateTime,
|
|
16
|
+
Integer,
|
|
17
|
+
MetaData,
|
|
18
|
+
String,
|
|
19
|
+
Table,
|
|
20
|
+
or_,
|
|
21
|
+
select,
|
|
22
|
+
update,
|
|
23
|
+
)
|
|
24
|
+
from sqlalchemy.engine import Engine
|
|
25
|
+
from sqlalchemy.orm import Session
|
|
26
|
+
|
|
27
|
+
from webhook_platform_sdk.producer import ApiError, WebhookPlatformError
|
|
28
|
+
|
|
29
|
+
EnvelopeMode = Literal["native", "cloudevents"]
|
|
30
|
+
metadata = MetaData()
|
|
31
|
+
outbox_events = Table(
|
|
32
|
+
"webhook_outbox",
|
|
33
|
+
metadata,
|
|
34
|
+
Column("id", String(36), primary_key=True),
|
|
35
|
+
Column("event_type", String(150), nullable=False),
|
|
36
|
+
Column("payload", JSON, nullable=False),
|
|
37
|
+
Column("idempotency_key", String(255), nullable=False, unique=True),
|
|
38
|
+
Column("envelope_mode", String(16), nullable=False, default="native"),
|
|
39
|
+
Column("lease_token", String(36)),
|
|
40
|
+
Column("locked_until", DateTime(timezone=True)),
|
|
41
|
+
Column("attempt_count", Integer, nullable=False, default=0),
|
|
42
|
+
Column("next_attempt_at", DateTime(timezone=True)),
|
|
43
|
+
Column("last_error_code", String(64)),
|
|
44
|
+
Column("created_at", DateTime(timezone=True), nullable=False),
|
|
45
|
+
Column("sent_at", DateTime(timezone=True)),
|
|
46
|
+
Column("failed_at", DateTime(timezone=True)),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EventProducer(Protocol):
|
|
51
|
+
def send_event(
|
|
52
|
+
self,
|
|
53
|
+
event_type: str,
|
|
54
|
+
payload: object,
|
|
55
|
+
idempotency_key: str,
|
|
56
|
+
*,
|
|
57
|
+
envelope_mode: EnvelopeMode = "native",
|
|
58
|
+
) -> dict[str, Any]: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def utcnow() -> datetime:
|
|
62
|
+
return datetime.now(timezone.utc)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_outbox_schema(engine: Engine) -> None:
|
|
66
|
+
"""Create the SDK-owned table; production users should use a migration."""
|
|
67
|
+
metadata.create_all(engine, tables=[outbox_events])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def enqueue_outbox(
|
|
71
|
+
session: Session,
|
|
72
|
+
event_type: str,
|
|
73
|
+
payload: object,
|
|
74
|
+
*,
|
|
75
|
+
idempotency_key: str | None = None,
|
|
76
|
+
envelope_mode: EnvelopeMode = "native",
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Insert into the caller's current transaction without committing it."""
|
|
79
|
+
if not event_type:
|
|
80
|
+
raise ValueError("event_type is required")
|
|
81
|
+
if envelope_mode not in {"native", "cloudevents"}:
|
|
82
|
+
raise ValueError("envelope_mode is invalid")
|
|
83
|
+
key = idempotency_key or str(uuid4())
|
|
84
|
+
created_at = utcnow()
|
|
85
|
+
session.execute(
|
|
86
|
+
outbox_events.insert().values(
|
|
87
|
+
id=str(uuid4()),
|
|
88
|
+
event_type=event_type,
|
|
89
|
+
payload=payload,
|
|
90
|
+
idempotency_key=key,
|
|
91
|
+
envelope_mode=envelope_mode,
|
|
92
|
+
attempt_count=0,
|
|
93
|
+
next_attempt_at=created_at,
|
|
94
|
+
created_at=created_at,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return key
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class OutboxMessage:
|
|
102
|
+
id: str
|
|
103
|
+
event_type: str
|
|
104
|
+
payload: object
|
|
105
|
+
idempotency_key: str
|
|
106
|
+
envelope_mode: EnvelopeMode
|
|
107
|
+
lease_token: str
|
|
108
|
+
attempt_number: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class OutboxRelay:
|
|
112
|
+
"""Claim briefly, send outside the transaction, then guard finalization."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
session_factory: Callable[[], Session],
|
|
117
|
+
producer: EventProducer,
|
|
118
|
+
*,
|
|
119
|
+
batch_size: int = 50,
|
|
120
|
+
lease_seconds: int = 60,
|
|
121
|
+
max_attempts: int = 20,
|
|
122
|
+
retry_base_seconds: float = 1.0,
|
|
123
|
+
retry_cap_seconds: float = 300.0,
|
|
124
|
+
) -> None:
|
|
125
|
+
if not 1 <= batch_size <= 1_000:
|
|
126
|
+
raise ValueError("batch_size must be between 1 and 1000")
|
|
127
|
+
if lease_seconds < 1:
|
|
128
|
+
raise ValueError("lease_seconds must be positive")
|
|
129
|
+
if max_attempts < 1:
|
|
130
|
+
raise ValueError("max_attempts must be positive")
|
|
131
|
+
if not 0 <= retry_base_seconds <= retry_cap_seconds:
|
|
132
|
+
raise ValueError("retry delay bounds are invalid")
|
|
133
|
+
self._session_factory = session_factory
|
|
134
|
+
self._producer = producer
|
|
135
|
+
self._batch_size = batch_size
|
|
136
|
+
self._lease_seconds = lease_seconds
|
|
137
|
+
self._max_attempts = max_attempts
|
|
138
|
+
self._retry_base_seconds = retry_base_seconds
|
|
139
|
+
self._retry_cap_seconds = retry_cap_seconds
|
|
140
|
+
self._random = SystemRandom()
|
|
141
|
+
|
|
142
|
+
def _claim(self) -> list[OutboxMessage]:
|
|
143
|
+
now = utcnow()
|
|
144
|
+
claimed: list[OutboxMessage] = []
|
|
145
|
+
with self._session_factory() as session, session.begin():
|
|
146
|
+
rows = session.execute(
|
|
147
|
+
select(outbox_events)
|
|
148
|
+
.where(
|
|
149
|
+
outbox_events.c.sent_at.is_(None),
|
|
150
|
+
outbox_events.c.failed_at.is_(None),
|
|
151
|
+
outbox_events.c.next_attempt_at <= now,
|
|
152
|
+
or_(
|
|
153
|
+
outbox_events.c.locked_until.is_(None),
|
|
154
|
+
outbox_events.c.locked_until <= now,
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
.order_by(outbox_events.c.created_at, outbox_events.c.id)
|
|
158
|
+
.limit(self._batch_size)
|
|
159
|
+
.with_for_update(skip_locked=True)
|
|
160
|
+
).mappings()
|
|
161
|
+
for row in rows:
|
|
162
|
+
token = str(uuid4())
|
|
163
|
+
session.execute(
|
|
164
|
+
update(outbox_events)
|
|
165
|
+
.where(outbox_events.c.id == row["id"])
|
|
166
|
+
.values(
|
|
167
|
+
lease_token=token,
|
|
168
|
+
locked_until=now
|
|
169
|
+
+ timedelta(seconds=self._lease_seconds),
|
|
170
|
+
attempt_count=outbox_events.c.attempt_count + 1,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
claimed.append(
|
|
174
|
+
OutboxMessage(
|
|
175
|
+
id=row["id"],
|
|
176
|
+
event_type=row["event_type"],
|
|
177
|
+
payload=row["payload"],
|
|
178
|
+
idempotency_key=row["idempotency_key"],
|
|
179
|
+
envelope_mode=row["envelope_mode"],
|
|
180
|
+
lease_token=token,
|
|
181
|
+
attempt_number=row["attempt_count"] + 1,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
return claimed
|
|
185
|
+
|
|
186
|
+
def _finish(
|
|
187
|
+
self,
|
|
188
|
+
message: OutboxMessage,
|
|
189
|
+
*,
|
|
190
|
+
sent: bool,
|
|
191
|
+
error_code: str | None = None,
|
|
192
|
+
retry_at: datetime | None = None,
|
|
193
|
+
terminal: bool = False,
|
|
194
|
+
) -> None:
|
|
195
|
+
values: dict[str, object | None] = {
|
|
196
|
+
"lease_token": None,
|
|
197
|
+
"locked_until": None,
|
|
198
|
+
"last_error_code": error_code,
|
|
199
|
+
"next_attempt_at": retry_at,
|
|
200
|
+
}
|
|
201
|
+
if sent:
|
|
202
|
+
values["sent_at"] = utcnow()
|
|
203
|
+
elif terminal:
|
|
204
|
+
values["failed_at"] = utcnow()
|
|
205
|
+
with self._session_factory() as session, session.begin():
|
|
206
|
+
session.execute(
|
|
207
|
+
update(outbox_events)
|
|
208
|
+
.where(
|
|
209
|
+
outbox_events.c.id == message.id,
|
|
210
|
+
outbox_events.c.lease_token == message.lease_token,
|
|
211
|
+
outbox_events.c.sent_at.is_(None),
|
|
212
|
+
)
|
|
213
|
+
.values(**values)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _record_failure(
|
|
217
|
+
self,
|
|
218
|
+
message: OutboxMessage,
|
|
219
|
+
code: str,
|
|
220
|
+
*,
|
|
221
|
+
retryable: bool,
|
|
222
|
+
retry_after: int | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
terminal = (
|
|
225
|
+
not retryable or message.attempt_number >= self._max_attempts
|
|
226
|
+
)
|
|
227
|
+
retry_at: datetime | None = None
|
|
228
|
+
if not terminal:
|
|
229
|
+
cap = min(
|
|
230
|
+
self._retry_cap_seconds,
|
|
231
|
+
self._retry_base_seconds * 2 ** (message.attempt_number - 1),
|
|
232
|
+
)
|
|
233
|
+
delay = self._random.uniform(0, cap)
|
|
234
|
+
if retry_after is not None:
|
|
235
|
+
delay = max(delay, min(retry_after, self._retry_cap_seconds))
|
|
236
|
+
retry_at = utcnow() + timedelta(seconds=delay)
|
|
237
|
+
self._finish(
|
|
238
|
+
message,
|
|
239
|
+
sent=False,
|
|
240
|
+
error_code=code[:64],
|
|
241
|
+
retry_at=retry_at,
|
|
242
|
+
terminal=terminal,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def run_once(self) -> tuple[int, int]:
|
|
246
|
+
"""Return successful and failed counts for one bounded batch.
|
|
247
|
+
|
|
248
|
+
A crash after API acceptance but before `_finish` causes another relay
|
|
249
|
+
attempt with the same idempotency key. Transient retries use bounded
|
|
250
|
+
full jitter and stop at `max_attempts`; terminal API responses stop
|
|
251
|
+
immediately.
|
|
252
|
+
"""
|
|
253
|
+
succeeded = 0
|
|
254
|
+
failed = 0
|
|
255
|
+
for message in self._claim():
|
|
256
|
+
try:
|
|
257
|
+
self._producer.send_event(
|
|
258
|
+
message.event_type,
|
|
259
|
+
message.payload,
|
|
260
|
+
message.idempotency_key,
|
|
261
|
+
envelope_mode=message.envelope_mode,
|
|
262
|
+
)
|
|
263
|
+
except ApiError as exc:
|
|
264
|
+
retryable = exc.status_code in {408, 425, 429, 503}
|
|
265
|
+
retryable = retryable or exc.status_code >= 500
|
|
266
|
+
self._record_failure(
|
|
267
|
+
message,
|
|
268
|
+
exc.code,
|
|
269
|
+
retryable=retryable,
|
|
270
|
+
retry_after=exc.retry_after,
|
|
271
|
+
)
|
|
272
|
+
failed += 1
|
|
273
|
+
except WebhookPlatformError as exc:
|
|
274
|
+
self._record_failure(
|
|
275
|
+
message, type(exc).__name__, retryable=True
|
|
276
|
+
)
|
|
277
|
+
failed += 1
|
|
278
|
+
else:
|
|
279
|
+
self._finish(message, sent=True)
|
|
280
|
+
succeeded += 1
|
|
281
|
+
return succeeded, failed
|