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.
@@ -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