modul-x / tests /test_retry_worker.py
sitcod3's picture
Upload folder using huggingface_hub
58d0e85 verified
Raw History Blame Contribute Delete
8.06 kB
import json
import unittest
from app.models import SMTPResult
from app.validator.retry_queue import (
MemoryRetryStore,
RedisRetryStore,
RetryEntry,
RetryQueue,
drain_retry_queue,
)
class FakeRedis:
"""Minimal async Redis subset (hash + zset) used by RedisRetryStore."""
def __init__(self):
self.hashes: dict[str, dict[str, str]] = {}
self.zsets: dict[str, dict[str, float]] = {}
async def hset(self, key, field, value):
self.hashes.setdefault(key, {})[field] = value
async def hget(self, key, field):
return self.hashes.get(key, {}).get(field)
async def hdel(self, key, field):
self.hashes.get(key, {}).pop(field, None)
async def zadd(self, key, mapping):
self.zsets.setdefault(key, {}).update(mapping)
async def zrem(self, key, member):
self.zsets.get(key, {}).pop(member, None)
async def zrangebyscore(self, key, min_, max_, start=0, num=None):
items = [
(m, s) for m, s in self.zsets.get(key, {}).items() if min_ <= s <= max_
]
items.sort(key=lambda kv: kv[1])
members = [m for m, _ in items]
return members[start : (start + num) if num else None]
async def zcard(self, key):
return len(self.zsets.get(key, {}))
async def hlen(self, key):
return len(self.hashes.get(key, {}))
class RedisRetryStoreTests(unittest.IsolatedAsyncioTestCase):
"""Production needs the queue to survive a restart and be shared across
workers — an in-process dict does neither."""
async def test_add_persists_entry(self):
store = RedisRetryStore(client=FakeRedis())
entry = RetryEntry(email="a@corp.test", mx_host="mx.corp.test", due_at=500.0)
await store.add(entry)
got = await store.get("a@corp.test")
self.assertIsNotNone(got)
self.assertEqual(got.mx_host, "mx.corp.test")
self.assertEqual(got.due_at, 500.0)
async def test_due_returns_only_matured_entries(self):
store = RedisRetryStore(client=FakeRedis())
await store.add(RetryEntry(email="early@corp.test", mx_host="mx", due_at=100.0))
await store.add(RetryEntry(email="late@corp.test", mx_host="mx", due_at=9999.0))
due = await store.due(now=200.0, limit=10)
self.assertEqual([e.email for e in due], ["early@corp.test"])
async def test_due_is_ordered_by_time(self):
store = RedisRetryStore(client=FakeRedis())
await store.add(RetryEntry(email="b@corp.test", mx_host="mx", due_at=300.0))
await store.add(RetryEntry(email="a@corp.test", mx_host="mx", due_at=200.0))
due = await store.due(now=400.0, limit=10)
self.assertEqual([e.email for e in due], ["a@corp.test", "b@corp.test"])
async def test_remove_clears_both_indexes(self):
redis = FakeRedis()
store = RedisRetryStore(client=redis)
await store.add(RetryEntry(email="a@corp.test", mx_host="mx", due_at=1.0))
await store.remove("a@corp.test")
self.assertIsNone(await store.get("a@corp.test"))
self.assertEqual(await store.count(), 0)
async def test_readd_updates_rather_than_duplicates(self):
store = RedisRetryStore(client=FakeRedis())
await store.add(RetryEntry(email="a@corp.test", mx_host="mx", due_at=1.0))
await store.add(RetryEntry(email="a@corp.test", mx_host="mx", due_at=99.0, attempts=1))
self.assertEqual(await store.count(), 1)
self.assertEqual((await store.get("a@corp.test")).attempts, 1)
async def test_corrupt_entry_is_skipped_not_raised(self):
redis = FakeRedis()
store = RedisRetryStore(client=redis)
await store.add(RetryEntry(email="ok@corp.test", mx_host="mx", due_at=1.0))
# Simulate a half-written/garbage payload for another member.
redis.hashes.setdefault("vx:retry:entries", {})["bad@corp.test"] = "{not json"
redis.zsets.setdefault("vx:retry:due", {})["bad@corp.test"] = 1.0
due = await store.due(now=5.0, limit=10)
self.assertEqual([e.email for e in due], ["ok@corp.test"])
class DrainWorkerTests(unittest.IsolatedAsyncioTestCase):
"""A queue that only fills is a leak. Due entries must actually be retried,
and a definitive answer must clear the entry."""
async def test_drain_reprobes_due_entries_and_resolves_them(self):
clock = {"t": 0.0}
queue = RetryQueue(
store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"]
)
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 100.0
probed: list[tuple[str, str]] = []
async def prober(email, mx_host):
probed.append((email, mx_host))
return SMTPResult(verified=True, smtp_code=250)
stats = await drain_retry_queue(prober, queue=queue, limit=10)
self.assertEqual(probed, [("a@corp.test", "mx.corp.test")])
self.assertEqual(stats["resolved"], 1)
self.assertEqual(stats["requeued"], 0)
self.assertEqual(await queue.size(), 0)
async def test_still_greylisted_entry_is_requeued_with_backoff(self):
clock = {"t": 0.0}
queue = RetryQueue(
store=MemoryRetryStore(), backoff_seconds=10, max_attempts=3,
now=lambda: clock["t"],
)
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 100.0
async def prober(email, mx_host):
return SMTPResult(verified=None, smtp_code=451, smtp_message="still greylisted")
stats = await drain_retry_queue(prober, queue=queue, limit=10)
self.assertEqual(stats["requeued"], 1)
self.assertEqual(await queue.size(), 1)
# Backed off: not immediately due again.
self.assertEqual(await queue.due(), [])
async def test_rejected_entry_is_resolved_and_removed(self):
clock = {"t": 0.0}
queue = RetryQueue(store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"])
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 100.0
async def prober(email, mx_host):
return SMTPResult(verified=False, smtp_code=550, smtp_message="550 no such user")
stats = await drain_retry_queue(prober, queue=queue, limit=10)
self.assertEqual(stats["resolved"], 1)
self.assertEqual(await queue.size(), 0)
async def test_prober_error_requeues_without_crashing(self):
clock = {"t": 0.0}
queue = RetryQueue(store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"])
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 100.0
async def prober(email, mx_host):
raise RuntimeError("mx exploded")
stats = await drain_retry_queue(prober, queue=queue, limit=10)
self.assertEqual(stats["errors"], 1)
self.assertEqual(await queue.size(), 1, "must stay queued for another try")
async def test_empty_queue_is_a_noop(self):
queue = RetryQueue(store=MemoryRetryStore(), now=lambda: 0.0)
async def prober(email, mx_host):
raise AssertionError("should not be called")
stats = await drain_retry_queue(prober, queue=queue, limit=10)
self.assertEqual(
stats, {"checked": 0, "resolved": 0, "requeued": 0, "errors": 0, "results": []}
)
async def test_results_are_returned_for_persistence(self):
clock = {"t": 0.0}
queue = RetryQueue(store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"])
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
clock["t"] = 100.0
async def prober(email, mx_host):
return SMTPResult(verified=True, smtp_code=250)
stats = await drain_retry_queue(prober, queue=queue, limit=10)
self.assertEqual(stats["results"][0][0], "a@corp.test")
self.assertTrue(stats["results"][0][1].verified)
if __name__ == "__main__":
unittest.main()