modul-x / tests /test_retry_queue.py
sitcod3's picture
Upload folder using huggingface_hub
58d0e85 verified
Raw History Blame Contribute Delete
6.03 kB
import unittest
from app.validator.retry_queue import (
MemoryRetryStore,
RetryEntry,
RetryQueue,
is_greylist_result,
)
class GreylistDetectionTests(unittest.TestCase):
"""Only a genuine SMTP 4xx (greylisting / temporary mailbox error) is worth
retrying. A transport failure is systemic to the MX — re-queuing every
address behind it would flood the queue for no gain."""
def _result(self, **kw):
from app.models import SMTPResult
base = dict(verified=None)
base.update(kw)
return SMTPResult(**base)
def test_4xx_is_retryable(self):
self.assertTrue(is_greylist_result(self._result(smtp_code=451)))
self.assertTrue(is_greylist_result(self._result(smtp_code=421)))
def test_5xx_is_not_retryable(self):
self.assertFalse(is_greylist_result(self._result(verified=False, smtp_code=550)))
def test_250_is_not_retryable(self):
self.assertFalse(is_greylist_result(self._result(verified=True, smtp_code=250)))
def test_transport_failure_is_not_retryable(self):
# Systemic: affects every address at that MX. Retrying per-address would
# flood the queue; the MX-level circuit breaker handles this instead.
self.assertFalse(
is_greylist_result(self._result(error="transport_error: connect timed out"))
)
def test_none_result_is_not_retryable(self):
self.assertFalse(is_greylist_result(None))
class RetryQueueTests(unittest.IsolatedAsyncioTestCase):
async def test_enqueue_schedules_a_future_attempt(self):
q = RetryQueue(store=MemoryRetryStore(), backoff_seconds=900, now=lambda: 1000.0)
ok = await q.enqueue("a@corp.test", "mx.corp.test", code=451)
self.assertTrue(ok)
self.assertEqual(await q.size(), 1)
# Not due yet.
self.assertEqual(await q.due(), [])
async def test_entry_becomes_due_after_backoff(self):
clock = {"t": 1000.0}
q = RetryQueue(store=MemoryRetryStore(), backoff_seconds=900, now=lambda: clock["t"])
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 1901.0
due = await q.due()
self.assertEqual(len(due), 1)
self.assertEqual(due[0].email, "a@corp.test")
self.assertEqual(due[0].mx_host, "mx.corp.test")
async def test_duplicate_enqueue_does_not_create_twice(self):
q = RetryQueue(store=MemoryRetryStore(), now=lambda: 1.0)
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
self.assertEqual(await q.size(), 1)
async def test_email_matching_is_case_insensitive(self):
q = RetryQueue(store=MemoryRetryStore(), now=lambda: 1.0)
await q.enqueue("A@Corp.test", "mx.corp.test", code=451)
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
self.assertEqual(await q.size(), 1)
async def test_attempt_increments_and_reschedules_with_backoff(self):
clock = {"t": 0.0}
q = RetryQueue(
store=MemoryRetryStore(), backoff_seconds=600, max_attempts=3, now=lambda: clock["t"]
)
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 601.0
entry = (await q.due())[0]
await q.mark_attempted(entry)
# Still queued (2nd attempt allowed), and not immediately due.
self.assertEqual(await q.size(), 1)
self.assertEqual(await q.due(), [])
clock["t"] = 601.0 + 1200.0 # exponential: 600 * 2
self.assertEqual(len(await q.due()), 1)
async def test_max_attempts_removes_the_entry(self):
clock = {"t": 0.0}
q = RetryQueue(
store=MemoryRetryStore(), backoff_seconds=10, max_attempts=2, now=lambda: clock["t"]
)
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 11.0
await q.mark_attempted((await q.due())[0])
clock["t"] = 40.0
await q.mark_attempted((await q.due())[0])
self.assertEqual(await q.size(), 0, "must not retry forever")
async def test_resolve_removes_the_entry(self):
clock = {"t": 0.0}
q = RetryQueue(store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"])
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 11.0
await q.resolve((await q.due())[0])
self.assertEqual(await q.size(), 0)
async def test_due_respects_limit(self):
clock = {"t": 0.0}
q = RetryQueue(store=MemoryRetryStore(), backoff_seconds=1, now=lambda: clock["t"])
for i in range(5):
await q.enqueue(f"u{i}@corp.test", "mx.corp.test", code=451)
clock["t"] = 5.0
self.assertEqual(len(await q.due(limit=2)), 2)
async def test_store_failure_does_not_raise(self):
class BrokenStore:
async def add(self, *a, **k):
raise RuntimeError("redis down")
async def due(self, *a, **k):
raise RuntimeError("redis down")
async def remove(self, *a, **k):
raise RuntimeError("redis down")
async def get(self, *a, **k):
raise RuntimeError("redis down")
async def count(self, *a, **k):
raise RuntimeError("redis down")
q = RetryQueue(store=BrokenStore())
self.assertFalse(await q.enqueue("a@corp.test", "mx.corp.test", code=451))
self.assertEqual(await q.due(), [])
self.assertEqual(await q.size(), 0)
class RetryEntryTests(unittest.TestCase):
def test_entry_serialises(self):
e = RetryEntry(email="a@corp.test", mx_host="mx.corp.test", attempts=1, due_at=123.0, last_code=451)
data = e.as_dict()
self.assertEqual(data["email"], "a@corp.test")
back = RetryEntry.from_dict(data)
self.assertEqual(back.email, e.email)
self.assertEqual(back.due_at, e.due_at)
if __name__ == "__main__":
unittest.main()