edgesync 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,529 @@
1
+ """SQLite durable storage backend.
2
+
3
+ Design notes (see docs/storage.md for the full writeup):
4
+
5
+ * A single ``aiosqlite`` connection is used per ``SQLiteStorage`` instance,
6
+ opened in autocommit mode (``isolation_level=None``). Single-statement
7
+ writes (claim, mark_delivered, schedule_retry, ...) commit atomically as
8
+ soon as they execute -- no explicit transaction needed. The one
9
+ compound read-then-write operation (``enqueue``'s capacity check) wraps
10
+ itself in an explicit ``BEGIN IMMEDIATE`` / ``COMMIT`` guarded by an
11
+ ``asyncio.Lock`` so concurrent ``enqueue`` calls on the same connection
12
+ never try to open two transactions at once.
13
+ * ``PRAGMA journal_mode=WAL`` + ``PRAGMA synchronous=FULL`` by default:
14
+ WAL for concurrent readers during writes, and ``synchronous=FULL`` so a
15
+ commit is not reported successful until it is durable on disk -- this
16
+ library explicitly has to survive power loss, not just process crashes.
17
+ Callers that can tolerate a small durability/performance trade-off may
18
+ pass ``synchronous="NORMAL"``.
19
+ * Message ownership across workers is enforced with a lease fencing token
20
+ (``lease_id``): every completion write is qualified with
21
+ ``WHERE id = ? AND lease_id = ? AND status = 'IN_FLIGHT'``, so a worker
22
+ whose lease already expired and was reclaimed by someone else can never
23
+ clobber the new owner's state.
24
+ * Delivered messages are removed from the ``messages`` table immediately
25
+ (a cumulative counter in ``counters`` tracks the all-time total) to keep
26
+ the on-disk queue bounded for storage-constrained edge devices.
27
+ Dead-lettered messages are retained until a caller retries or deletes
28
+ them explicitly.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import json
35
+ import os
36
+ from datetime import datetime, timedelta, timezone
37
+
38
+ import aiosqlite
39
+
40
+ from edgesync.config import OverflowPolicy
41
+ from edgesync.exceptions import QueueFullError, StorageError
42
+ from edgesync.logging import logger
43
+ from edgesync.models.message import Message, MessageStatus
44
+ from edgesync.models.stats import QueueStats
45
+ from edgesync.storage.base import StorageBackend
46
+ from edgesync.storage.migrations import apply_migrations
47
+ from edgesync.utils.clock import Clock, SystemClock
48
+ from edgesync.utils.ids import generate_lease_id
49
+
50
+ _ACTIVE_STATUSES = (MessageStatus.PENDING.value, MessageStatus.IN_FLIGHT.value)
51
+
52
+
53
+ def _resolve_database_path(database: str) -> str:
54
+ if database == ":memory:":
55
+ return database
56
+ if database.startswith("sqlite:///"):
57
+ return database[len("sqlite:///") :]
58
+ if database.startswith("sqlite://"):
59
+ return database[len("sqlite://") :]
60
+ return database
61
+
62
+
63
+ def _dt_to_str(value: datetime | None) -> str | None:
64
+ if value is None:
65
+ return None
66
+ return value.astimezone(timezone.utc).isoformat()
67
+
68
+
69
+ def _str_to_dt(value: str | None) -> datetime | None:
70
+ if value is None:
71
+ return None
72
+ return datetime.fromisoformat(value)
73
+
74
+
75
+ class SQLiteStorage(StorageBackend):
76
+ """A durable, single-file SQLite-backed queue."""
77
+
78
+ def __init__(
79
+ self,
80
+ database: str,
81
+ *,
82
+ clock: Clock | None = None,
83
+ synchronous: str = "FULL",
84
+ ) -> None:
85
+ self._path = _resolve_database_path(database)
86
+ self._clock = clock or SystemClock()
87
+ self._synchronous = synchronous
88
+ self._conn: aiosqlite.Connection | None = None
89
+ self._write_lock = asyncio.Lock()
90
+
91
+ @property
92
+ def _connection(self) -> aiosqlite.Connection:
93
+ if self._conn is None:
94
+ raise StorageError("SQLiteStorage has not been initialized; call initialize() first")
95
+ return self._conn
96
+
97
+ async def initialize(self) -> None:
98
+ if self._conn is not None:
99
+ return
100
+ directory = os.path.dirname(self._path)
101
+ if directory:
102
+ os.makedirs(directory, exist_ok=True)
103
+
104
+ try:
105
+ conn = await aiosqlite.connect(self._path, isolation_level=None)
106
+ except Exception as exc:
107
+ raise StorageError(f"failed to open database at {self._path!r}: {exc}") from exc
108
+
109
+ conn.row_factory = aiosqlite.Row
110
+ await conn.execute("PRAGMA foreign_keys = ON")
111
+ await conn.execute("PRAGMA busy_timeout = 30000")
112
+ if self._path != ":memory:":
113
+ await conn.execute("PRAGMA journal_mode = WAL")
114
+ await conn.execute(f"PRAGMA synchronous = {self._synchronous}")
115
+
116
+ self._conn = conn
117
+ await apply_migrations(conn)
118
+
119
+ recovered = await self.recover_expired_leases()
120
+ if recovered:
121
+ logger.info("recovered %d message(s) with expired leases on startup", recovered)
122
+
123
+ async def close(self) -> None:
124
+ if self._conn is not None:
125
+ await self._conn.close()
126
+ self._conn = None
127
+
128
+ # -- enqueue ---------------------------------------------------------
129
+
130
+ async def enqueue(
131
+ self,
132
+ message: Message,
133
+ *,
134
+ max_messages: int | None = None,
135
+ max_storage_bytes: int | None = None,
136
+ overflow_policy: OverflowPolicy = OverflowPolicy.REJECT_NEW,
137
+ ) -> None:
138
+ conn = self._connection
139
+ async with self._write_lock:
140
+ await conn.execute("BEGIN IMMEDIATE")
141
+ try:
142
+ await self._enforce_capacity(
143
+ message,
144
+ max_messages=max_messages,
145
+ max_storage_bytes=max_storage_bytes,
146
+ overflow_policy=overflow_policy,
147
+ )
148
+ await conn.execute(
149
+ """
150
+ INSERT INTO messages (
151
+ id, destination, payload, headers, metadata, priority,
152
+ status, attempts, created_at, updated_at, next_attempt_at,
153
+ last_attempt_at, delivered_at, last_error, lease_id,
154
+ lease_until, expires_at, size_bytes
155
+ ) VALUES (
156
+ :id, :destination, :payload, :headers, :metadata, :priority,
157
+ :status, :attempts, :created_at, :updated_at, :next_attempt_at,
158
+ :last_attempt_at, :delivered_at, :last_error, :lease_id,
159
+ :lease_until, :expires_at, :size_bytes
160
+ )
161
+ """,
162
+ self._message_to_row(message),
163
+ )
164
+ await conn.execute("COMMIT")
165
+ except BaseException:
166
+ await conn.execute("ROLLBACK")
167
+ raise
168
+
169
+ async def _enforce_capacity(
170
+ self,
171
+ message: Message,
172
+ *,
173
+ max_messages: int | None,
174
+ max_storage_bytes: int | None,
175
+ overflow_policy: OverflowPolicy,
176
+ ) -> None:
177
+ conn = self._connection
178
+
179
+ if max_storage_bytes is not None and message.size_bytes > max_storage_bytes:
180
+ raise QueueFullError(
181
+ f"message ({message.size_bytes} bytes) exceeds max_storage_bytes="
182
+ f"{max_storage_bytes} and can never be enqueued"
183
+ )
184
+ if max_messages is None and max_storage_bytes is None:
185
+ return
186
+
187
+ max_iterations = 10_000 # safety bound against pathological loops
188
+ for _ in range(max_iterations):
189
+ cursor = await conn.execute(
190
+ "SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) FROM messages "
191
+ f"WHERE status IN ('{_ACTIVE_STATUSES[0]}', '{_ACTIVE_STATUSES[1]}')"
192
+ )
193
+ row = await cursor.fetchone()
194
+ assert row is not None # COUNT(*)/SUM always return exactly one row
195
+ count, total_bytes = row[0], row[1]
196
+
197
+ over_count = max_messages is not None and count >= max_messages
198
+ over_bytes = (
199
+ max_storage_bytes is not None
200
+ and (total_bytes + message.size_bytes) > max_storage_bytes
201
+ )
202
+ if not over_count and not over_bytes:
203
+ return
204
+
205
+ if overflow_policy is OverflowPolicy.REJECT_NEW:
206
+ raise QueueFullError(
207
+ f"queue is full (messages={count}, bytes={total_bytes}); "
208
+ "overflow_policy=reject_new"
209
+ )
210
+
211
+ victim_id = await self._select_eviction_victim(overflow_policy)
212
+ if victim_id is None:
213
+ raise QueueFullError(
214
+ "queue is full and no evictable message is available "
215
+ "(all active messages are currently in flight)"
216
+ )
217
+ await self._evict(victim_id, overflow_policy)
218
+
219
+ raise QueueFullError("queue is full and could not be reduced below capacity")
220
+
221
+ async def _select_eviction_victim(self, overflow_policy: OverflowPolicy) -> str | None:
222
+ conn = self._connection
223
+ if overflow_policy in (OverflowPolicy.DROP_OLDEST, OverflowPolicy.DEAD_LETTER):
224
+ order_by = "created_at ASC"
225
+ elif overflow_policy is OverflowPolicy.DROP_LOWEST_PRIORITY:
226
+ order_by = "priority ASC, created_at ASC"
227
+ else:
228
+ return None
229
+ cursor = await conn.execute(
230
+ f"SELECT id FROM messages WHERE status = 'PENDING' ORDER BY {order_by} LIMIT 1"
231
+ )
232
+ row = await cursor.fetchone()
233
+ return row[0] if row else None
234
+
235
+ async def _evict(self, message_id: str, overflow_policy: OverflowPolicy) -> None:
236
+ conn = self._connection
237
+ now = _dt_to_str(self._clock.now())
238
+ if overflow_policy is OverflowPolicy.DEAD_LETTER:
239
+ logger.warning("queue full: moving message %s to dead-letter to make room", message_id)
240
+ await conn.execute(
241
+ "UPDATE messages SET status = 'DEAD_LETTER', last_error = :error, "
242
+ "updated_at = :now WHERE id = :id",
243
+ {"error": "evicted: queue capacity exceeded", "now": now, "id": message_id},
244
+ )
245
+ else:
246
+ logger.warning(
247
+ "queue full: dropping message %s (overflow_policy=%s)",
248
+ message_id,
249
+ overflow_policy.value,
250
+ )
251
+ await conn.execute("DELETE FROM messages WHERE id = :id", {"id": message_id})
252
+
253
+ # -- claim / complete --------------------------------------------------
254
+
255
+ async def claim_batch(self, limit: int, lease_duration: float) -> list[Message]:
256
+ conn = self._connection
257
+ now = self._clock.now()
258
+ lease_id = generate_lease_id()
259
+ lease_until = now + timedelta(seconds=lease_duration)
260
+
261
+ async with self._write_lock:
262
+ await conn.execute("BEGIN IMMEDIATE")
263
+ try:
264
+ await conn.execute(
265
+ """
266
+ UPDATE messages
267
+ SET status = 'IN_FLIGHT',
268
+ lease_id = :lease_id,
269
+ lease_until = :lease_until,
270
+ updated_at = :now
271
+ WHERE id IN (
272
+ SELECT id FROM messages
273
+ WHERE status = 'PENDING'
274
+ AND next_attempt_at <= :now
275
+ ORDER BY priority DESC, next_attempt_at ASC, created_at ASC
276
+ LIMIT :limit
277
+ )
278
+ """,
279
+ {
280
+ "lease_id": lease_id,
281
+ "lease_until": _dt_to_str(lease_until),
282
+ "now": _dt_to_str(now),
283
+ "limit": limit,
284
+ },
285
+ )
286
+ cursor = await conn.execute(
287
+ "SELECT * FROM messages WHERE lease_id = :lease_id", {"lease_id": lease_id}
288
+ )
289
+ rows = await cursor.fetchall()
290
+ await conn.execute("COMMIT")
291
+ except BaseException:
292
+ await conn.execute("ROLLBACK")
293
+ raise
294
+
295
+ messages = [self._row_to_message(row) for row in rows]
296
+ if messages:
297
+ logger.debug("claimed %d message(s) with lease %s", len(messages), lease_id)
298
+ return messages
299
+
300
+ async def mark_delivered(self, message_id: str, lease_id: str) -> None:
301
+ conn = self._connection
302
+ now = _dt_to_str(self._clock.now())
303
+ cursor = await conn.execute(
304
+ "UPDATE messages SET status = 'DELIVERED', delivered_at = :now, "
305
+ "updated_at = :now WHERE id = :id AND lease_id = :lease_id AND status = 'IN_FLIGHT'",
306
+ {"now": now, "id": message_id, "lease_id": lease_id},
307
+ )
308
+ if cursor.rowcount == 0:
309
+ logger.warning(
310
+ "mark_delivered: message %s no longer owned by lease %s (stale lease?)",
311
+ message_id,
312
+ lease_id,
313
+ )
314
+ return
315
+ await conn.execute("UPDATE counters SET value = value + 1 WHERE key = 'delivered_total'")
316
+ await conn.execute("DELETE FROM messages WHERE id = :id", {"id": message_id})
317
+
318
+ async def schedule_retry(
319
+ self,
320
+ message_id: str,
321
+ lease_id: str,
322
+ error: str,
323
+ next_attempt_at: datetime,
324
+ ) -> None:
325
+ conn = self._connection
326
+ now = _dt_to_str(self._clock.now())
327
+ cursor = await conn.execute(
328
+ """
329
+ UPDATE messages
330
+ SET status = 'PENDING',
331
+ attempts = attempts + 1,
332
+ last_error = :error,
333
+ last_attempt_at = :now,
334
+ next_attempt_at = :next_attempt_at,
335
+ lease_id = NULL,
336
+ lease_until = NULL,
337
+ updated_at = :now
338
+ WHERE id = :id AND lease_id = :lease_id AND status = 'IN_FLIGHT'
339
+ """,
340
+ {
341
+ "error": error[:2000],
342
+ "now": now,
343
+ "next_attempt_at": _dt_to_str(next_attempt_at),
344
+ "id": message_id,
345
+ "lease_id": lease_id,
346
+ },
347
+ )
348
+ if cursor.rowcount == 0:
349
+ logger.warning(
350
+ "schedule_retry: message %s no longer owned by lease %s (stale lease?)",
351
+ message_id,
352
+ lease_id,
353
+ )
354
+
355
+ async def move_to_dead_letter(self, message_id: str, lease_id: str, error: str) -> None:
356
+ conn = self._connection
357
+ now = _dt_to_str(self._clock.now())
358
+ cursor = await conn.execute(
359
+ """
360
+ UPDATE messages
361
+ SET status = 'DEAD_LETTER',
362
+ last_error = :error,
363
+ last_attempt_at = :now,
364
+ updated_at = :now,
365
+ lease_id = NULL,
366
+ lease_until = NULL
367
+ WHERE id = :id AND lease_id = :lease_id AND status = 'IN_FLIGHT'
368
+ """,
369
+ {"error": error[:2000], "now": now, "id": message_id, "lease_id": lease_id},
370
+ )
371
+ if cursor.rowcount == 0:
372
+ logger.warning(
373
+ "move_to_dead_letter: message %s no longer owned by lease %s (stale lease?)",
374
+ message_id,
375
+ lease_id,
376
+ )
377
+ else:
378
+ logger.warning("message %s moved to dead-letter: %s", message_id, error)
379
+
380
+ # -- recovery / expiry -------------------------------------------------
381
+
382
+ async def recover_expired_leases(self) -> int:
383
+ conn = self._connection
384
+ now = _dt_to_str(self._clock.now())
385
+ cursor = await conn.execute(
386
+ """
387
+ UPDATE messages
388
+ SET status = 'PENDING', lease_id = NULL, lease_until = NULL, updated_at = :now
389
+ WHERE status = 'IN_FLIGHT' AND lease_until <= :now
390
+ """,
391
+ {"now": now},
392
+ )
393
+ return cursor.rowcount
394
+
395
+ async def expire_stale_messages(self) -> int:
396
+ conn = self._connection
397
+ now = _dt_to_str(self._clock.now())
398
+ cursor = await conn.execute(
399
+ """
400
+ UPDATE messages
401
+ SET status = 'DEAD_LETTER', last_error = 'message expired', updated_at = :now
402
+ WHERE status = 'PENDING' AND expires_at IS NOT NULL AND expires_at <= :now
403
+ """,
404
+ {"now": now},
405
+ )
406
+ return cursor.rowcount
407
+
408
+ # -- stats / dead-letter management ------------------------------------
409
+
410
+ async def get_stats(self) -> QueueStats:
411
+ conn = self._connection
412
+ now = _dt_to_str(self._clock.now())
413
+ cursor = await conn.execute(
414
+ """
415
+ SELECT
416
+ SUM(CASE WHEN status = 'PENDING' AND next_attempt_at <= :now THEN 1 ELSE 0 END),
417
+ SUM(CASE WHEN status = 'PENDING' AND next_attempt_at > :now THEN 1 ELSE 0 END),
418
+ SUM(CASE WHEN status = 'IN_FLIGHT' THEN 1 ELSE 0 END),
419
+ SUM(CASE WHEN status = 'DEAD_LETTER' THEN 1 ELSE 0 END)
420
+ FROM messages
421
+ """,
422
+ {"now": now},
423
+ )
424
+ row = await cursor.fetchone()
425
+ assert row is not None # aggregate SUM(...) always returns exactly one row
426
+ pending, retrying, in_flight, dead_letter = (value or 0 for value in row)
427
+
428
+ counter_cursor = await conn.execute(
429
+ "SELECT value FROM counters WHERE key = 'delivered_total'"
430
+ )
431
+ counter_row = await counter_cursor.fetchone()
432
+ delivered_total = counter_row[0] if counter_row else 0
433
+
434
+ storage_bytes = None
435
+ if self._path != ":memory:" and os.path.exists(self._path):
436
+ storage_bytes = os.path.getsize(self._path)
437
+
438
+ return QueueStats(
439
+ pending=pending,
440
+ in_flight=in_flight,
441
+ delivered=delivered_total,
442
+ dead_letter=dead_letter,
443
+ retrying=retrying,
444
+ storage_bytes=storage_bytes,
445
+ )
446
+
447
+ async def get_message(self, message_id: str) -> Message | None:
448
+ conn = self._connection
449
+ cursor = await conn.execute("SELECT * FROM messages WHERE id = :id", {"id": message_id})
450
+ row = await cursor.fetchone()
451
+ return self._row_to_message(row) if row else None
452
+
453
+ async def list_dead_letters(self, limit: int = 100, offset: int = 0) -> list[Message]:
454
+ conn = self._connection
455
+ cursor = await conn.execute(
456
+ "SELECT * FROM messages WHERE status = 'DEAD_LETTER' "
457
+ "ORDER BY updated_at DESC LIMIT :limit OFFSET :offset",
458
+ {"limit": limit, "offset": offset},
459
+ )
460
+ rows = await cursor.fetchall()
461
+ return [self._row_to_message(row) for row in rows]
462
+
463
+ async def retry_dead_letter(self, message_id: str) -> None:
464
+ conn = self._connection
465
+ now = _dt_to_str(self._clock.now())
466
+ await conn.execute(
467
+ """
468
+ UPDATE messages
469
+ SET status = 'PENDING', attempts = 0, last_error = NULL,
470
+ next_attempt_at = :now, updated_at = :now
471
+ WHERE id = :id AND status = 'DEAD_LETTER'
472
+ """,
473
+ {"now": now, "id": message_id},
474
+ )
475
+
476
+ async def delete_message(self, message_id: str) -> None:
477
+ conn = self._connection
478
+ await conn.execute("DELETE FROM messages WHERE id = :id", {"id": message_id})
479
+
480
+ # -- row <-> model mapping ----------------------------------------------
481
+
482
+ @staticmethod
483
+ def _message_to_row(message: Message) -> dict[str, object]:
484
+ return {
485
+ "id": message.id,
486
+ "destination": message.destination,
487
+ "payload": json.dumps(message.payload),
488
+ "headers": json.dumps(message.headers),
489
+ "metadata": json.dumps(message.metadata),
490
+ "priority": message.priority,
491
+ "status": message.status.value,
492
+ "attempts": message.attempts,
493
+ "created_at": _dt_to_str(message.created_at),
494
+ "updated_at": _dt_to_str(message.updated_at),
495
+ "next_attempt_at": _dt_to_str(message.next_attempt_at),
496
+ "last_attempt_at": _dt_to_str(message.last_attempt_at),
497
+ "delivered_at": _dt_to_str(message.delivered_at),
498
+ "last_error": message.last_error,
499
+ "lease_id": message.lease_id,
500
+ "lease_until": _dt_to_str(message.lease_until),
501
+ "expires_at": _dt_to_str(message.expires_at),
502
+ "size_bytes": message.size_bytes,
503
+ }
504
+
505
+ @staticmethod
506
+ def _row_to_message(row: aiosqlite.Row) -> Message:
507
+ assert row["next_attempt_at"] is not None
508
+ assert row["created_at"] is not None
509
+ assert row["updated_at"] is not None
510
+ return Message(
511
+ id=row["id"],
512
+ destination=row["destination"],
513
+ payload=json.loads(row["payload"]),
514
+ headers=json.loads(row["headers"]),
515
+ metadata=json.loads(row["metadata"]),
516
+ priority=row["priority"],
517
+ status=MessageStatus(row["status"]),
518
+ attempts=row["attempts"],
519
+ created_at=_str_to_dt(row["created_at"]), # type: ignore[arg-type]
520
+ updated_at=_str_to_dt(row["updated_at"]), # type: ignore[arg-type]
521
+ next_attempt_at=_str_to_dt(row["next_attempt_at"]), # type: ignore[arg-type]
522
+ last_attempt_at=_str_to_dt(row["last_attempt_at"]),
523
+ delivered_at=_str_to_dt(row["delivered_at"]),
524
+ last_error=row["last_error"],
525
+ lease_id=row["lease_id"],
526
+ lease_until=_str_to_dt(row["lease_until"]),
527
+ expires_at=_str_to_dt(row["expires_at"]),
528
+ size_bytes=row["size_bytes"],
529
+ )
@@ -0,0 +1,7 @@
1
+ """Delivery transports."""
2
+
3
+ from edgesync.transports.base import Transport
4
+ from edgesync.transports.http import HTTPTransport
5
+ from edgesync.transports.registry import TransportRegistry
6
+
7
+ __all__ = ["Transport", "HTTPTransport", "TransportRegistry"]
@@ -0,0 +1,30 @@
1
+ """The transport abstraction.
2
+
3
+ Transports are responsible only for attempting delivery of a single message
4
+ and classifying the outcome. They must never touch storage or retry
5
+ scheduling -- that is the worker's responsibility, driven by the
6
+ ``DeliveryResult`` a transport returns.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+
13
+ from edgesync.models.delivery import DeliveryResult
14
+ from edgesync.models.message import Message
15
+
16
+
17
+ class Transport(ABC):
18
+ """A destination EdgeSync can deliver messages to."""
19
+
20
+ @abstractmethod
21
+ async def start(self) -> None:
22
+ """Acquire any resources needed for delivery (e.g. open a client)."""
23
+
24
+ @abstractmethod
25
+ async def deliver(self, message: Message) -> DeliveryResult:
26
+ """Attempt to deliver a single message and classify the outcome."""
27
+
28
+ @abstractmethod
29
+ async def close(self) -> None:
30
+ """Release resources acquired in :meth:`start`."""
@@ -0,0 +1,112 @@
1
+ """Production HTTP transport backed by ``httpx``.
2
+
3
+ A single ``httpx.AsyncClient`` is created in :meth:`start` and reused for
4
+ every delivery -- connections are pooled, not reopened per message. The
5
+ message ID is always sent via a configurable idempotency header (default
6
+ ``Idempotency-Key``) so destinations can de-duplicate retried deliveries,
7
+ which is required for at-least-once delivery to be safe to build on.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable
13
+
14
+ import httpx
15
+
16
+ from edgesync.models.delivery import DeliveryResult
17
+ from edgesync.models.message import Message
18
+ from edgesync.transports.base import Transport
19
+
20
+ DEFAULT_RETRYABLE_STATUS_CODES = frozenset({408, 425, 429, *range(500, 600)})
21
+ _RESPONSE_SNIPPET_LIMIT = 500
22
+
23
+
24
+ class HTTPTransport(Transport):
25
+ """Deliver messages as JSON over HTTP."""
26
+
27
+ def __init__(
28
+ self,
29
+ url: str,
30
+ *,
31
+ method: str = "POST",
32
+ timeout: float = 30.0,
33
+ headers: dict[str, str] | None = None,
34
+ idempotency_header: str = "Idempotency-Key",
35
+ success_status_codes: Iterable[int] | None = None,
36
+ retryable_status_codes: Iterable[int] = DEFAULT_RETRYABLE_STATUS_CODES,
37
+ verify: bool = True,
38
+ client: httpx.AsyncClient | None = None,
39
+ ) -> None:
40
+ self._url = url
41
+ self._method = method
42
+ self._timeout = timeout
43
+ self._default_headers = dict(headers or {})
44
+ self._idempotency_header = idempotency_header
45
+ self._success_status_codes = (
46
+ frozenset(success_status_codes)
47
+ if success_status_codes is not None
48
+ else None # None means "any 2xx"
49
+ )
50
+ self._retryable_status_codes = frozenset(retryable_status_codes)
51
+ self._verify = verify
52
+ self._external_client = client
53
+ self._client: httpx.AsyncClient | None = None
54
+
55
+ async def start(self) -> None:
56
+ if self._client is not None:
57
+ return
58
+ if self._external_client is not None:
59
+ self._client = self._external_client
60
+ else:
61
+ self._client = httpx.AsyncClient(
62
+ timeout=self._timeout,
63
+ verify=self._verify,
64
+ )
65
+
66
+ async def close(self) -> None:
67
+ if self._client is not None and self._external_client is None:
68
+ await self._client.aclose()
69
+ self._client = None
70
+
71
+ async def deliver(self, message: Message) -> DeliveryResult:
72
+ if self._client is None:
73
+ raise RuntimeError("HTTPTransport.start() must be called before deliver()")
74
+
75
+ headers = {**self._default_headers, **message.headers}
76
+ headers[self._idempotency_header] = message.id
77
+
78
+ try:
79
+ response = await self._client.request(
80
+ self._method,
81
+ self._url,
82
+ json=message.payload,
83
+ headers=headers,
84
+ timeout=self._timeout,
85
+ )
86
+ except httpx.TimeoutException as exc:
87
+ return DeliveryResult.retryable_failure(f"timeout: {exc}")
88
+ except httpx.ConnectError as exc:
89
+ return DeliveryResult.retryable_failure(f"connection failed: {exc}")
90
+ except httpx.TransportError as exc:
91
+ # Covers DNS failures, network errors, protocol errors, etc.
92
+ return DeliveryResult.retryable_failure(f"transport error: {exc}")
93
+
94
+ return self._classify(response)
95
+
96
+ def _classify(self, response: httpx.Response) -> DeliveryResult:
97
+ status = response.status_code
98
+ is_success = (
99
+ status in self._success_status_codes
100
+ if self._success_status_codes is not None
101
+ else 200 <= status < 300
102
+ )
103
+ if is_success:
104
+ return DeliveryResult.success_result(status_code=status)
105
+
106
+ snippet = response.text[:_RESPONSE_SNIPPET_LIMIT]
107
+ error = f"HTTP {status}"
108
+ if status in self._retryable_status_codes:
109
+ return DeliveryResult.retryable_failure(
110
+ error, status_code=status, response_snippet=snippet
111
+ )
112
+ return DeliveryResult.permanent_failure(error, status_code=status, response_snippet=snippet)