Upload folder using huggingface_hub
Browse files- app/tasks/celery_tasks.py +59 -0
- app/validator/engine.py +24 -0
- app/validator/retry_queue.py +312 -0
- tests/test_engine_retry.py +168 -0
- tests/test_retry_queue.py +153 -0
- tests/test_retry_worker.py +202 -0
app/tasks/celery_tasks.py
CHANGED
|
@@ -31,9 +31,68 @@ celery_app.conf.update(
|
|
| 31 |
task_acks_late=True,
|
| 32 |
worker_prefetch_multiplier=1,
|
| 33 |
task_reject_on_worker_lost=True,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
)
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
@celery_app.task(bind=True, max_retries=3, rate_limit="100/m")
|
| 38 |
def bulk_validate_task(self, emails: list, webhook_url: str = None, deep: bool = True):
|
| 39 |
"""
|
|
|
|
| 31 |
task_acks_late=True,
|
| 32 |
worker_prefetch_multiplier=1,
|
| 33 |
task_reject_on_worker_lost=True,
|
| 34 |
+
# Drain the greylist retry queue periodically. Without a beat schedule the
|
| 35 |
+
# queue would only ever fill — a slow leak of unretried addresses.
|
| 36 |
+
beat_schedule={
|
| 37 |
+
"drain-greylist-retries": {
|
| 38 |
+
"task": "app.tasks.celery_tasks.drain_retries_task",
|
| 39 |
+
"schedule": 300.0, # every 5 minutes
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
)
|
| 43 |
|
| 44 |
|
| 45 |
+
@celery_app.task(bind=True, max_retries=1, rate_limit="12/m")
|
| 46 |
+
def drain_retries_task(self, limit: int = 50):
|
| 47 |
+
"""Re-probe addresses that were greylisted earlier.
|
| 48 |
+
|
| 49 |
+
Scheduled by beat every 5 minutes. Without this the retry queue would only
|
| 50 |
+
fill, leaving valid mailboxes on greylisting domains stranded as "risky".
|
| 51 |
+
"""
|
| 52 |
+
from ..validator.engine import EmailValidationEngine
|
| 53 |
+
from ..validator.retry_queue import drain_retry_queue, retry_queue
|
| 54 |
+
import sys
|
| 55 |
+
|
| 56 |
+
async def run():
|
| 57 |
+
eng = EmailValidationEngine()
|
| 58 |
+
|
| 59 |
+
async def prober(email: str, mx_host: str):
|
| 60 |
+
return await eng.mailbox.verify(email, mx_host)
|
| 61 |
+
|
| 62 |
+
stats = await drain_retry_queue(prober, queue=retry_queue, limit=limit)
|
| 63 |
+
|
| 64 |
+
# Cache the resolved verdicts so History/Results and future runs see the
|
| 65 |
+
# retry's outcome (a valid mailbox stops being reported as risky).
|
| 66 |
+
cached = 0
|
| 67 |
+
for email, result in stats.get("results", []):
|
| 68 |
+
if result.verified is None:
|
| 69 |
+
continue
|
| 70 |
+
try:
|
| 71 |
+
await eng.cache.put_verdict(email, True, result, None)
|
| 72 |
+
cached += 1
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
print(f"[RETRY] could not cache {email}: {exc}", file=sys.stderr)
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"checked": stats["checked"],
|
| 78 |
+
"resolved": stats["resolved"],
|
| 79 |
+
"requeued": stats["requeued"],
|
| 80 |
+
"errors": stats["errors"],
|
| 81 |
+
"cached": cached,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
loop = asyncio.new_event_loop()
|
| 86 |
+
asyncio.set_event_loop(loop)
|
| 87 |
+
try:
|
| 88 |
+
return loop.run_until_complete(run())
|
| 89 |
+
finally:
|
| 90 |
+
loop.close()
|
| 91 |
+
except Exception as exc:
|
| 92 |
+
print(f"[RETRY] drain failed: {exc}", file=sys.stderr)
|
| 93 |
+
return {"error": str(exc)}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
@celery_app.task(bind=True, max_retries=3, rate_limit="100/m")
|
| 97 |
def bulk_validate_task(self, emails: list, webhook_url: str = None, deep: bool = True):
|
| 98 |
"""
|
app/validator/engine.py
CHANGED
|
@@ -20,6 +20,7 @@ from .syntax import validate_syntax
|
|
| 20 |
from .dns_check import check_dns
|
| 21 |
from .disposable import check_disposable
|
| 22 |
from .mailbox_provider import MailboxProvider, resolve_provider
|
|
|
|
| 23 |
from .scoring import calculate_score
|
| 24 |
|
| 25 |
logger = structlog.get_logger()
|
|
@@ -123,6 +124,7 @@ class EmailValidationEngine:
|
|
| 123 |
self,
|
| 124 |
mailbox: MailboxProvider | None = None,
|
| 125 |
cache=None,
|
|
|
|
| 126 |
):
|
| 127 |
self.use_proxies = settings.use_proxies
|
| 128 |
# Layers 5 (catch-all) and 6 (SMTP) run through a mailbox provider. On
|
|
@@ -136,6 +138,12 @@ class EmailValidationEngine:
|
|
| 136 |
from .validation_cache import validation_cache as cache
|
| 137 |
|
| 138 |
self.cache = cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
async def validate(self, email: str, deep: bool = True) -> ValidationResult:
|
| 141 |
start_time = time.monotonic()
|
|
@@ -524,6 +532,22 @@ class EmailValidationEngine:
|
|
| 524 |
confidence=0.9 if ca_value is not None else 0.0,
|
| 525 |
)
|
| 526 |
disposable = check_disposable(normalized, domain)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
scoring = calculate_score(
|
| 528 |
syntax, dns, disposable, catch_all, smtp
|
| 529 |
)
|
|
|
|
| 20 |
from .dns_check import check_dns
|
| 21 |
from .disposable import check_disposable
|
| 22 |
from .mailbox_provider import MailboxProvider, resolve_provider
|
| 23 |
+
from .retry_queue import is_greylist_result
|
| 24 |
from .scoring import calculate_score
|
| 25 |
|
| 26 |
logger = structlog.get_logger()
|
|
|
|
| 124 |
self,
|
| 125 |
mailbox: MailboxProvider | None = None,
|
| 126 |
cache=None,
|
| 127 |
+
retry_queue=None,
|
| 128 |
):
|
| 129 |
self.use_proxies = settings.use_proxies
|
| 130 |
# Layers 5 (catch-all) and 6 (SMTP) run through a mailbox provider. On
|
|
|
|
| 138 |
from .validation_cache import validation_cache as cache
|
| 139 |
|
| 140 |
self.cache = cache
|
| 141 |
+
# Greylisted (4xx) addresses get a bounded second chance. Without this a
|
| 142 |
+
# valid mailbox on a greylisting domain stays permanently "risky".
|
| 143 |
+
if retry_queue is None:
|
| 144 |
+
from .retry_queue import retry_queue as retry_queue
|
| 145 |
+
|
| 146 |
+
self.retry_queue = retry_queue
|
| 147 |
|
| 148 |
async def validate(self, email: str, deep: bool = True) -> ValidationResult:
|
| 149 |
start_time = time.monotonic()
|
|
|
|
| 532 |
confidence=0.9 if ca_value is not None else 0.0,
|
| 533 |
)
|
| 534 |
disposable = check_disposable(normalized, domain)
|
| 535 |
+
|
| 536 |
+
# A 4xx means "come back later", not a verdict. Schedule one
|
| 537 |
+
# bounded retry so a real mailbox on a greylisting domain is not
|
| 538 |
+
# stranded as permanently risky. The queue never raises.
|
| 539 |
+
if cached is None:
|
| 540 |
+
try:
|
| 541 |
+
if is_greylist_result(smtp):
|
| 542 |
+
await self.retry_queue.enqueue(
|
| 543 |
+
normalized, dns.mx_records[0]["host"], smtp.smtp_code
|
| 544 |
+
)
|
| 545 |
+
elif smtp.verified is not None:
|
| 546 |
+
# Definitive answer — cancel any pending retry.
|
| 547 |
+
await self.retry_queue.resolve_email(normalized)
|
| 548 |
+
except Exception:
|
| 549 |
+
pass
|
| 550 |
+
|
| 551 |
scoring = calculate_score(
|
| 552 |
syntax, dns, disposable, catch_all, smtp
|
| 553 |
)
|
app/validator/retry_queue.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Greylist retry queue.
|
| 2 |
+
|
| 3 |
+
Real mail servers often answer the first RCPT for an unknown sender with a
|
| 4 |
+
**4xx** (greylisting / "try again later"). That is not a verdict — it is an
|
| 5 |
+
instruction to come back. Without a retry, a perfectly valid mailbox on such a
|
| 6 |
+
domain stays permanently "risky", which is exactly the false-negative that makes
|
| 7 |
+
a validator feel unreliable.
|
| 8 |
+
|
| 9 |
+
Two rules keep this safe:
|
| 10 |
+
|
| 11 |
+
* **Only 4xx is retried.** A 5xx is final, a 250 is done. A *transport*
|
| 12 |
+
failure (connect timeout / unreachable) is systemic to the MX — re-queuing
|
| 13 |
+
every address behind a dead host would flood the queue for no gain, so the
|
| 14 |
+
MX-level circuit breaker handles that instead.
|
| 15 |
+
* **Bounded attempts with exponential backoff.** A queue that retries forever
|
| 16 |
+
is a queue that never drains; entries are dropped after `max_attempts`.
|
| 17 |
+
|
| 18 |
+
The store is pluggable (Redis in production, memory in tests) and every store
|
| 19 |
+
error degrades to "no queue" rather than breaking a validation run.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
import time
|
| 25 |
+
from dataclasses import asdict, dataclass
|
| 26 |
+
from typing import Awaitable, Callable, Optional, Protocol
|
| 27 |
+
|
| 28 |
+
from ..models import SMTPResult
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def is_greylist_result(smtp: Optional[SMTPResult]) -> bool:
|
| 32 |
+
"""True only for a genuine SMTP 4xx — the retryable case."""
|
| 33 |
+
if smtp is None:
|
| 34 |
+
return False
|
| 35 |
+
code = smtp.smtp_code
|
| 36 |
+
return code is not None and 400 <= code < 500
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class RetryEntry:
|
| 41 |
+
email: str
|
| 42 |
+
mx_host: str
|
| 43 |
+
attempts: int = 0
|
| 44 |
+
due_at: float = 0.0
|
| 45 |
+
last_code: Optional[int] = None
|
| 46 |
+
|
| 47 |
+
def as_dict(self) -> dict:
|
| 48 |
+
return asdict(self)
|
| 49 |
+
|
| 50 |
+
@classmethod
|
| 51 |
+
def from_dict(cls, data: dict) -> "RetryEntry":
|
| 52 |
+
return cls(
|
| 53 |
+
email=data["email"],
|
| 54 |
+
mx_host=data["mx_host"],
|
| 55 |
+
attempts=int(data.get("attempts", 0)),
|
| 56 |
+
due_at=float(data.get("due_at", 0.0)),
|
| 57 |
+
last_code=data.get("last_code"),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class RetryStore(Protocol):
|
| 62 |
+
async def add(self, entry: RetryEntry) -> None: ...
|
| 63 |
+
async def due(self, now: float, limit: int) -> list[RetryEntry]: ...
|
| 64 |
+
async def remove(self, email: str) -> None: ...
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class MemoryRetryStore:
|
| 68 |
+
"""In-process store. Adequate for a single worker or tests."""
|
| 69 |
+
|
| 70 |
+
def __init__(self) -> None:
|
| 71 |
+
self._items: dict[str, RetryEntry] = {}
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
def _key(email: str) -> str:
|
| 75 |
+
return email.strip().lower()
|
| 76 |
+
|
| 77 |
+
async def add(self, entry: RetryEntry) -> None:
|
| 78 |
+
self._items[self._key(entry.email)] = entry
|
| 79 |
+
|
| 80 |
+
async def due(self, now: float, limit: int) -> list[RetryEntry]:
|
| 81 |
+
ready = [e for e in self._items.values() if e.due_at <= now]
|
| 82 |
+
ready.sort(key=lambda e: e.due_at)
|
| 83 |
+
return ready[:limit]
|
| 84 |
+
|
| 85 |
+
async def remove(self, email: str) -> None:
|
| 86 |
+
self._items.pop(self._key(email), None)
|
| 87 |
+
|
| 88 |
+
async def get(self, email: str) -> Optional[RetryEntry]:
|
| 89 |
+
return self._items.get(self._key(email))
|
| 90 |
+
|
| 91 |
+
async def count(self) -> int:
|
| 92 |
+
return len(self._items)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class RedisRetryStore:
|
| 96 |
+
"""Durable, worker-shared store: a hash of entries plus a sorted-set index.
|
| 97 |
+
|
| 98 |
+
The sorted set is scored by `due_at`, so "what is due now" is one
|
| 99 |
+
ZRANGEBYSCORE rather than a full scan of every pending address.
|
| 100 |
+
"""
|
| 101 |
+
|
| 102 |
+
HASH_KEY = "vx:retry:entries"
|
| 103 |
+
DUE_KEY = "vx:retry:due"
|
| 104 |
+
|
| 105 |
+
def __init__(self, client=None):
|
| 106 |
+
self._client = client
|
| 107 |
+
|
| 108 |
+
async def _redis(self):
|
| 109 |
+
if self._client is not None:
|
| 110 |
+
return self._client
|
| 111 |
+
from ..utils.cache import get_redis
|
| 112 |
+
|
| 113 |
+
self._client = await get_redis()
|
| 114 |
+
return self._client
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
def _key(email: str) -> str:
|
| 118 |
+
return email.strip().lower()
|
| 119 |
+
|
| 120 |
+
async def add(self, entry: RetryEntry) -> None:
|
| 121 |
+
client = await self._redis()
|
| 122 |
+
if client is None:
|
| 123 |
+
raise RuntimeError("redis unavailable")
|
| 124 |
+
key = self._key(entry.email)
|
| 125 |
+
await client.hset(self.HASH_KEY, key, json.dumps(entry.as_dict()))
|
| 126 |
+
await client.zadd(self.DUE_KEY, {key: entry.due_at})
|
| 127 |
+
|
| 128 |
+
async def due(self, now: float, limit: int) -> list[RetryEntry]:
|
| 129 |
+
client = await self._redis()
|
| 130 |
+
if client is None:
|
| 131 |
+
raise RuntimeError("redis unavailable")
|
| 132 |
+
members = await client.zrangebyscore(self.DUE_KEY, float("-inf"), now, start=0, num=limit)
|
| 133 |
+
entries: list[RetryEntry] = []
|
| 134 |
+
for member in members:
|
| 135 |
+
raw = await client.hget(self.HASH_KEY, member)
|
| 136 |
+
if not raw:
|
| 137 |
+
# Index without a payload (partial write): drop the orphan.
|
| 138 |
+
await client.zrem(self.DUE_KEY, member)
|
| 139 |
+
continue
|
| 140 |
+
try:
|
| 141 |
+
entries.append(RetryEntry.from_dict(json.loads(raw)))
|
| 142 |
+
except Exception:
|
| 143 |
+
# Corrupt payload must be skipped, never crash the drain.
|
| 144 |
+
await client.zrem(self.DUE_KEY, member)
|
| 145 |
+
await client.hdel(self.HASH_KEY, member)
|
| 146 |
+
return entries
|
| 147 |
+
|
| 148 |
+
async def remove(self, email: str) -> None:
|
| 149 |
+
client = await self._redis()
|
| 150 |
+
if client is None:
|
| 151 |
+
raise RuntimeError("redis unavailable")
|
| 152 |
+
key = self._key(email)
|
| 153 |
+
await client.hdel(self.HASH_KEY, key)
|
| 154 |
+
await client.zrem(self.DUE_KEY, key)
|
| 155 |
+
|
| 156 |
+
async def get(self, email: str) -> Optional[RetryEntry]:
|
| 157 |
+
client = await self._redis()
|
| 158 |
+
if client is None:
|
| 159 |
+
raise RuntimeError("redis unavailable")
|
| 160 |
+
raw = await client.hget(self.HASH_KEY, self._key(email))
|
| 161 |
+
if not raw:
|
| 162 |
+
return None
|
| 163 |
+
try:
|
| 164 |
+
return RetryEntry.from_dict(json.loads(raw))
|
| 165 |
+
except Exception:
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
async def count(self) -> int:
|
| 169 |
+
client = await self._redis()
|
| 170 |
+
if client is None:
|
| 171 |
+
raise RuntimeError("redis unavailable")
|
| 172 |
+
return int(await client.hlen(self.HASH_KEY) or 0)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def build_retry_store():
|
| 176 |
+
"""Redis when configured/reachable, else the in-process store."""
|
| 177 |
+
try:
|
| 178 |
+
from ..utils.cache import _HAS_REDIS # type: ignore[attr-defined]
|
| 179 |
+
|
| 180 |
+
if _HAS_REDIS:
|
| 181 |
+
return RedisRetryStore()
|
| 182 |
+
except Exception:
|
| 183 |
+
pass
|
| 184 |
+
return MemoryRetryStore()
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class RetryQueue:
|
| 188 |
+
"""Schedules greylisted addresses for a later attempt."""
|
| 189 |
+
|
| 190 |
+
def __init__(
|
| 191 |
+
self,
|
| 192 |
+
store: Optional[RetryStore] = None,
|
| 193 |
+
backoff_seconds: int = 900, # 15 min — inside the usual greylist window
|
| 194 |
+
max_attempts: int = 3,
|
| 195 |
+
now: Optional[Callable[[], float]] = None,
|
| 196 |
+
):
|
| 197 |
+
self.store = store or MemoryRetryStore()
|
| 198 |
+
self.backoff_seconds = max(1, backoff_seconds)
|
| 199 |
+
self.max_attempts = max(1, max_attempts)
|
| 200 |
+
self._now = now or time.time
|
| 201 |
+
|
| 202 |
+
def _delay_for(self, attempts: int) -> float:
|
| 203 |
+
# attempts=0 -> base, 1 -> 2x base, 2 -> 4x base (capped growth).
|
| 204 |
+
return self.backoff_seconds * (2 ** min(attempts, 4))
|
| 205 |
+
|
| 206 |
+
async def enqueue(self, email: str, mx_host: str, code: Optional[int] = None) -> bool:
|
| 207 |
+
"""Schedule `email` for a later attempt. Returns False if not queued."""
|
| 208 |
+
try:
|
| 209 |
+
existing = None
|
| 210 |
+
if hasattr(self.store, "get"):
|
| 211 |
+
existing = await self.store.get(email) # type: ignore[attr-defined]
|
| 212 |
+
if existing is not None:
|
| 213 |
+
return False # already queued; never create duplicates
|
| 214 |
+
entry = RetryEntry(
|
| 215 |
+
email=email,
|
| 216 |
+
mx_host=mx_host,
|
| 217 |
+
attempts=0,
|
| 218 |
+
due_at=self._now() + self._delay_for(0),
|
| 219 |
+
last_code=code,
|
| 220 |
+
)
|
| 221 |
+
await self.store.add(entry)
|
| 222 |
+
return True
|
| 223 |
+
except Exception:
|
| 224 |
+
return False # a queue outage must not break validation
|
| 225 |
+
|
| 226 |
+
async def due(self, limit: int = 50) -> list[RetryEntry]:
|
| 227 |
+
try:
|
| 228 |
+
return await self.store.due(self._now(), limit)
|
| 229 |
+
except Exception:
|
| 230 |
+
return []
|
| 231 |
+
|
| 232 |
+
async def mark_attempted(self, entry: RetryEntry) -> None:
|
| 233 |
+
"""Record an attempt; reschedule with backoff or drop when exhausted."""
|
| 234 |
+
try:
|
| 235 |
+
attempts = entry.attempts + 1
|
| 236 |
+
if attempts >= self.max_attempts:
|
| 237 |
+
await self.store.remove(entry.email)
|
| 238 |
+
return
|
| 239 |
+
entry.attempts = attempts
|
| 240 |
+
entry.due_at = self._now() + self._delay_for(attempts)
|
| 241 |
+
await self.store.add(entry)
|
| 242 |
+
except Exception:
|
| 243 |
+
return
|
| 244 |
+
|
| 245 |
+
async def resolve(self, entry: RetryEntry) -> None:
|
| 246 |
+
"""The address got a definitive answer — stop retrying it."""
|
| 247 |
+
try:
|
| 248 |
+
await self.store.remove(entry.email)
|
| 249 |
+
except Exception:
|
| 250 |
+
return
|
| 251 |
+
|
| 252 |
+
async def resolve_email(self, email: str) -> None:
|
| 253 |
+
"""Convenience: stop retrying by address (definitive verdict arrived)."""
|
| 254 |
+
try:
|
| 255 |
+
await self.store.remove(email)
|
| 256 |
+
except Exception:
|
| 257 |
+
return
|
| 258 |
+
|
| 259 |
+
async def size(self) -> int:
|
| 260 |
+
try:
|
| 261 |
+
if hasattr(self.store, "count"):
|
| 262 |
+
return await self.store.count() # type: ignore[attr-defined]
|
| 263 |
+
return len(await self.store.due(float("inf"), 10_000))
|
| 264 |
+
except Exception:
|
| 265 |
+
return 0
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
async def drain_retry_queue(
|
| 269 |
+
prober: Callable[[str, str], Awaitable[SMTPResult]],
|
| 270 |
+
queue: Optional[RetryQueue] = None,
|
| 271 |
+
limit: int = 50,
|
| 272 |
+
) -> dict:
|
| 273 |
+
"""Re-probe addresses that were greylisted earlier.
|
| 274 |
+
|
| 275 |
+
A queue that only fills is a leak, so this is the other half of the feature.
|
| 276 |
+
For each due entry:
|
| 277 |
+
|
| 278 |
+
* definitive answer (250 or 5xx) -> drop it from the queue
|
| 279 |
+
* still 4xx -> re-queue with exponential backoff
|
| 280 |
+
* probe raised -> leave queued for a later attempt
|
| 281 |
+
|
| 282 |
+
Returns a stats dict including the raw results so the caller can persist them.
|
| 283 |
+
"""
|
| 284 |
+
q = queue or retry_queue
|
| 285 |
+
stats: dict = {"checked": 0, "resolved": 0, "requeued": 0, "errors": 0, "results": []}
|
| 286 |
+
|
| 287 |
+
for entry in await q.due(limit=limit):
|
| 288 |
+
stats["checked"] += 1
|
| 289 |
+
try:
|
| 290 |
+
result = await prober(entry.email, entry.mx_host)
|
| 291 |
+
except Exception:
|
| 292 |
+
# Transport-level blow-up: keep it queued, try again next drain.
|
| 293 |
+
stats["errors"] += 1
|
| 294 |
+
await q.mark_attempted(entry)
|
| 295 |
+
continue
|
| 296 |
+
|
| 297 |
+
stats["results"].append((entry.email, result))
|
| 298 |
+
|
| 299 |
+
if is_greylist_result(result):
|
| 300 |
+
stats["requeued"] += 1
|
| 301 |
+
await q.mark_attempted(entry)
|
| 302 |
+
else:
|
| 303 |
+
# 250 or 5xx — a real answer, so stop retrying this address.
|
| 304 |
+
stats["resolved"] += 1
|
| 305 |
+
await q.resolve(entry)
|
| 306 |
+
|
| 307 |
+
return stats
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# Shared queue used by the bulk pipeline. Prefers Redis so the queue survives a
|
| 311 |
+
# restart and is shared across workers; falls back to memory otherwise.
|
| 312 |
+
retry_queue = RetryQueue(store=build_retry_store())
|
tests/test_engine_retry.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import AsyncMock, patch
|
| 3 |
+
|
| 4 |
+
from app.models import DNSResult, DisposableResult, SMTPResult, ValidationStatus
|
| 5 |
+
from app.validator.engine import EmailValidationEngine
|
| 6 |
+
from app.validator.retry_queue import MemoryRetryStore, RetryQueue
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _dns(host="mx.corp.test"):
|
| 10 |
+
return DNSResult(
|
| 11 |
+
domain_exists=True,
|
| 12 |
+
has_mx_records=True,
|
| 13 |
+
mx_records=[{"host": host, "priority": 10}],
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _clean():
|
| 18 |
+
return DisposableResult(
|
| 19 |
+
is_disposable=False, is_free_provider=False, is_role_based=False
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class NullCache:
|
| 24 |
+
async def get_dns(self, domain):
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
async def put_dns(self, domain, result):
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
async def get_verdict(self, email, deep):
|
| 31 |
+
return None
|
| 32 |
+
|
| 33 |
+
async def put_verdict(self, email, deep, smtp, catch_all):
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ScriptedProvider:
|
| 38 |
+
def __init__(self, mapping):
|
| 39 |
+
self.mapping = mapping
|
| 40 |
+
|
| 41 |
+
async def verify_many(self, pairs):
|
| 42 |
+
return (
|
| 43 |
+
{e: self.mapping.get(e, SMTPResult(verified=True, smtp_code=250)) for e, _ in pairs},
|
| 44 |
+
{},
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
async def verify(self, email, mx_host):
|
| 48 |
+
return self.mapping.get(email, SMTPResult(verified=True, smtp_code=250))
|
| 49 |
+
|
| 50 |
+
async def catch_all(self, domain, mx_host):
|
| 51 |
+
return (False, 0.9)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class EngineRetryIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
| 55 |
+
"""4xx rows must be re-queued (bounded), definitive rows must not."""
|
| 56 |
+
|
| 57 |
+
async def test_greylisted_row_is_enqueued(self):
|
| 58 |
+
clock = {"t": 0.0}
|
| 59 |
+
queue = RetryQueue(store=MemoryRetryStore(), backoff_seconds=600, now=lambda: clock["t"])
|
| 60 |
+
engine = EmailValidationEngine(
|
| 61 |
+
mailbox=ScriptedProvider(
|
| 62 |
+
{"a@corp.test": SMTPResult(verified=None, smtp_code=451,
|
| 63 |
+
smtp_message="451 greylisted, try later")}
|
| 64 |
+
),
|
| 65 |
+
cache=NullCache(),
|
| 66 |
+
retry_queue=queue,
|
| 67 |
+
)
|
| 68 |
+
with (
|
| 69 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
|
| 70 |
+
patch("app.validator.engine.check_disposable", return_value=_clean()),
|
| 71 |
+
):
|
| 72 |
+
results = await engine.validate_batch(["a@corp.test"], deep=True)
|
| 73 |
+
|
| 74 |
+
self.assertEqual(await queue.size(), 1)
|
| 75 |
+
entry = (await queue.due(limit=1)) if clock["t"] > 600 else None
|
| 76 |
+
# Still pending (not yet due), but queued.
|
| 77 |
+
self.assertEqual(results[0].status, ValidationStatus.RISKY)
|
| 78 |
+
|
| 79 |
+
async def test_verified_row_is_not_enqueued(self):
|
| 80 |
+
queue = RetryQueue(store=MemoryRetryStore(), now=lambda: 0.0)
|
| 81 |
+
engine = EmailValidationEngine(
|
| 82 |
+
mailbox=ScriptedProvider(
|
| 83 |
+
{"a@corp.test": SMTPResult(verified=True, smtp_code=250)}
|
| 84 |
+
),
|
| 85 |
+
cache=NullCache(),
|
| 86 |
+
retry_queue=queue,
|
| 87 |
+
)
|
| 88 |
+
with (
|
| 89 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
|
| 90 |
+
patch("app.validator.engine.check_disposable", return_value=_clean()),
|
| 91 |
+
):
|
| 92 |
+
await engine.validate_batch(["a@corp.test"], deep=True)
|
| 93 |
+
|
| 94 |
+
self.assertEqual(await queue.size(), 0)
|
| 95 |
+
|
| 96 |
+
async def test_rejected_row_is_not_enqueued(self):
|
| 97 |
+
queue = RetryQueue(store=MemoryRetryStore(), now=lambda: 0.0)
|
| 98 |
+
engine = EmailValidationEngine(
|
| 99 |
+
mailbox=ScriptedProvider(
|
| 100 |
+
{"a@corp.test": SMTPResult(verified=False, smtp_code=550,
|
| 101 |
+
smtp_message="550 no such user")}
|
| 102 |
+
),
|
| 103 |
+
cache=NullCache(),
|
| 104 |
+
retry_queue=queue,
|
| 105 |
+
)
|
| 106 |
+
with (
|
| 107 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
|
| 108 |
+
patch("app.validator.engine.check_disposable", return_value=_clean()),
|
| 109 |
+
):
|
| 110 |
+
await engine.validate_batch(["a@corp.test"], deep=True)
|
| 111 |
+
|
| 112 |
+
self.assertEqual(await queue.size(), 0)
|
| 113 |
+
|
| 114 |
+
async def test_transport_failure_is_not_enqueued(self):
|
| 115 |
+
# Systemic to the MX — the circuit breaker owns this, not the queue.
|
| 116 |
+
queue = RetryQueue(store=MemoryRetryStore(), now=lambda: 0.0)
|
| 117 |
+
engine = EmailValidationEngine(
|
| 118 |
+
mailbox=ScriptedProvider(
|
| 119 |
+
{"a@corp.test": SMTPResult(verified=None, error="transport_error: timed out")}
|
| 120 |
+
),
|
| 121 |
+
cache=NullCache(),
|
| 122 |
+
retry_queue=queue,
|
| 123 |
+
)
|
| 124 |
+
with (
|
| 125 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
|
| 126 |
+
patch("app.validator.engine.check_disposable", return_value=_clean()),
|
| 127 |
+
):
|
| 128 |
+
await engine.validate_batch(["a@corp.test"], deep=True)
|
| 129 |
+
|
| 130 |
+
self.assertEqual(await queue.size(), 0)
|
| 131 |
+
|
| 132 |
+
async def test_queue_failure_does_not_break_validation(self):
|
| 133 |
+
class BrokenQueue:
|
| 134 |
+
async def enqueue(self, *a, **k):
|
| 135 |
+
raise RuntimeError("redis down")
|
| 136 |
+
|
| 137 |
+
engine = EmailValidationEngine(
|
| 138 |
+
mailbox=ScriptedProvider(
|
| 139 |
+
{"a@corp.test": SMTPResult(verified=None, smtp_code=451)}
|
| 140 |
+
),
|
| 141 |
+
cache=NullCache(),
|
| 142 |
+
retry_queue=BrokenQueue(),
|
| 143 |
+
)
|
| 144 |
+
with (
|
| 145 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
|
| 146 |
+
patch("app.validator.engine.check_disposable", return_value=_clean()),
|
| 147 |
+
):
|
| 148 |
+
results = await engine.validate_batch(["a@corp.test"], deep=True)
|
| 149 |
+
|
| 150 |
+
# The row still has a verdict; a broken queue must never lose it.
|
| 151 |
+
self.assertEqual(len(results), 1)
|
| 152 |
+
|
| 153 |
+
async def test_engine_without_queue_still_works(self):
|
| 154 |
+
engine = EmailValidationEngine(
|
| 155 |
+
mailbox=ScriptedProvider({"a@corp.test": SMTPResult(verified=True, smtp_code=250)}),
|
| 156 |
+
cache=NullCache(),
|
| 157 |
+
)
|
| 158 |
+
with (
|
| 159 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
|
| 160 |
+
patch("app.validator.engine.check_disposable", return_value=_clean()),
|
| 161 |
+
):
|
| 162 |
+
results = await engine.validate_batch(["a@corp.test"], deep=True)
|
| 163 |
+
|
| 164 |
+
self.assertEqual(results[0].status, ValidationStatus.VALID)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
unittest.main()
|
tests/test_retry_queue.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from app.validator.retry_queue import (
|
| 4 |
+
MemoryRetryStore,
|
| 5 |
+
RetryEntry,
|
| 6 |
+
RetryQueue,
|
| 7 |
+
is_greylist_result,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class GreylistDetectionTests(unittest.TestCase):
|
| 12 |
+
"""Only a genuine SMTP 4xx (greylisting / temporary mailbox error) is worth
|
| 13 |
+
retrying. A transport failure is systemic to the MX — re-queuing every
|
| 14 |
+
address behind it would flood the queue for no gain."""
|
| 15 |
+
|
| 16 |
+
def _result(self, **kw):
|
| 17 |
+
from app.models import SMTPResult
|
| 18 |
+
|
| 19 |
+
base = dict(verified=None)
|
| 20 |
+
base.update(kw)
|
| 21 |
+
return SMTPResult(**base)
|
| 22 |
+
|
| 23 |
+
def test_4xx_is_retryable(self):
|
| 24 |
+
self.assertTrue(is_greylist_result(self._result(smtp_code=451)))
|
| 25 |
+
self.assertTrue(is_greylist_result(self._result(smtp_code=421)))
|
| 26 |
+
|
| 27 |
+
def test_5xx_is_not_retryable(self):
|
| 28 |
+
self.assertFalse(is_greylist_result(self._result(verified=False, smtp_code=550)))
|
| 29 |
+
|
| 30 |
+
def test_250_is_not_retryable(self):
|
| 31 |
+
self.assertFalse(is_greylist_result(self._result(verified=True, smtp_code=250)))
|
| 32 |
+
|
| 33 |
+
def test_transport_failure_is_not_retryable(self):
|
| 34 |
+
# Systemic: affects every address at that MX. Retrying per-address would
|
| 35 |
+
# flood the queue; the MX-level circuit breaker handles this instead.
|
| 36 |
+
self.assertFalse(
|
| 37 |
+
is_greylist_result(self._result(error="transport_error: connect timed out"))
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def test_none_result_is_not_retryable(self):
|
| 41 |
+
self.assertFalse(is_greylist_result(None))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class RetryQueueTests(unittest.IsolatedAsyncioTestCase):
|
| 45 |
+
async def test_enqueue_schedules_a_future_attempt(self):
|
| 46 |
+
q = RetryQueue(store=MemoryRetryStore(), backoff_seconds=900, now=lambda: 1000.0)
|
| 47 |
+
ok = await q.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 48 |
+
self.assertTrue(ok)
|
| 49 |
+
self.assertEqual(await q.size(), 1)
|
| 50 |
+
# Not due yet.
|
| 51 |
+
self.assertEqual(await q.due(), [])
|
| 52 |
+
|
| 53 |
+
async def test_entry_becomes_due_after_backoff(self):
|
| 54 |
+
clock = {"t": 1000.0}
|
| 55 |
+
q = RetryQueue(store=MemoryRetryStore(), backoff_seconds=900, now=lambda: clock["t"])
|
| 56 |
+
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 57 |
+
clock["t"] = 1901.0
|
| 58 |
+
due = await q.due()
|
| 59 |
+
self.assertEqual(len(due), 1)
|
| 60 |
+
self.assertEqual(due[0].email, "a@corp.test")
|
| 61 |
+
self.assertEqual(due[0].mx_host, "mx.corp.test")
|
| 62 |
+
|
| 63 |
+
async def test_duplicate_enqueue_does_not_create_twice(self):
|
| 64 |
+
q = RetryQueue(store=MemoryRetryStore(), now=lambda: 1.0)
|
| 65 |
+
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 66 |
+
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 67 |
+
self.assertEqual(await q.size(), 1)
|
| 68 |
+
|
| 69 |
+
async def test_email_matching_is_case_insensitive(self):
|
| 70 |
+
q = RetryQueue(store=MemoryRetryStore(), now=lambda: 1.0)
|
| 71 |
+
await q.enqueue("A@Corp.test", "mx.corp.test", code=451)
|
| 72 |
+
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 73 |
+
self.assertEqual(await q.size(), 1)
|
| 74 |
+
|
| 75 |
+
async def test_attempt_increments_and_reschedules_with_backoff(self):
|
| 76 |
+
clock = {"t": 0.0}
|
| 77 |
+
q = RetryQueue(
|
| 78 |
+
store=MemoryRetryStore(), backoff_seconds=600, max_attempts=3, now=lambda: clock["t"]
|
| 79 |
+
)
|
| 80 |
+
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 81 |
+
clock["t"] = 601.0
|
| 82 |
+
entry = (await q.due())[0]
|
| 83 |
+
await q.mark_attempted(entry)
|
| 84 |
+
|
| 85 |
+
# Still queued (2nd attempt allowed), and not immediately due.
|
| 86 |
+
self.assertEqual(await q.size(), 1)
|
| 87 |
+
self.assertEqual(await q.due(), [])
|
| 88 |
+
clock["t"] = 601.0 + 1200.0 # exponential: 600 * 2
|
| 89 |
+
self.assertEqual(len(await q.due()), 1)
|
| 90 |
+
|
| 91 |
+
async def test_max_attempts_removes_the_entry(self):
|
| 92 |
+
clock = {"t": 0.0}
|
| 93 |
+
q = RetryQueue(
|
| 94 |
+
store=MemoryRetryStore(), backoff_seconds=10, max_attempts=2, now=lambda: clock["t"]
|
| 95 |
+
)
|
| 96 |
+
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 97 |
+
clock["t"] = 11.0
|
| 98 |
+
await q.mark_attempted((await q.due())[0])
|
| 99 |
+
clock["t"] = 40.0
|
| 100 |
+
await q.mark_attempted((await q.due())[0])
|
| 101 |
+
self.assertEqual(await q.size(), 0, "must not retry forever")
|
| 102 |
+
|
| 103 |
+
async def test_resolve_removes_the_entry(self):
|
| 104 |
+
clock = {"t": 0.0}
|
| 105 |
+
q = RetryQueue(store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"])
|
| 106 |
+
await q.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 107 |
+
clock["t"] = 11.0
|
| 108 |
+
await q.resolve((await q.due())[0])
|
| 109 |
+
self.assertEqual(await q.size(), 0)
|
| 110 |
+
|
| 111 |
+
async def test_due_respects_limit(self):
|
| 112 |
+
clock = {"t": 0.0}
|
| 113 |
+
q = RetryQueue(store=MemoryRetryStore(), backoff_seconds=1, now=lambda: clock["t"])
|
| 114 |
+
for i in range(5):
|
| 115 |
+
await q.enqueue(f"u{i}@corp.test", "mx.corp.test", code=451)
|
| 116 |
+
clock["t"] = 5.0
|
| 117 |
+
self.assertEqual(len(await q.due(limit=2)), 2)
|
| 118 |
+
|
| 119 |
+
async def test_store_failure_does_not_raise(self):
|
| 120 |
+
class BrokenStore:
|
| 121 |
+
async def add(self, *a, **k):
|
| 122 |
+
raise RuntimeError("redis down")
|
| 123 |
+
|
| 124 |
+
async def due(self, *a, **k):
|
| 125 |
+
raise RuntimeError("redis down")
|
| 126 |
+
|
| 127 |
+
async def remove(self, *a, **k):
|
| 128 |
+
raise RuntimeError("redis down")
|
| 129 |
+
|
| 130 |
+
async def get(self, *a, **k):
|
| 131 |
+
raise RuntimeError("redis down")
|
| 132 |
+
|
| 133 |
+
async def count(self, *a, **k):
|
| 134 |
+
raise RuntimeError("redis down")
|
| 135 |
+
|
| 136 |
+
q = RetryQueue(store=BrokenStore())
|
| 137 |
+
self.assertFalse(await q.enqueue("a@corp.test", "mx.corp.test", code=451))
|
| 138 |
+
self.assertEqual(await q.due(), [])
|
| 139 |
+
self.assertEqual(await q.size(), 0)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class RetryEntryTests(unittest.TestCase):
|
| 143 |
+
def test_entry_serialises(self):
|
| 144 |
+
e = RetryEntry(email="a@corp.test", mx_host="mx.corp.test", attempts=1, due_at=123.0, last_code=451)
|
| 145 |
+
data = e.as_dict()
|
| 146 |
+
self.assertEqual(data["email"], "a@corp.test")
|
| 147 |
+
back = RetryEntry.from_dict(data)
|
| 148 |
+
self.assertEqual(back.email, e.email)
|
| 149 |
+
self.assertEqual(back.due_at, e.due_at)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
unittest.main()
|
tests/test_retry_worker.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import unittest
|
| 3 |
+
|
| 4 |
+
from app.models import SMTPResult
|
| 5 |
+
from app.validator.retry_queue import (
|
| 6 |
+
MemoryRetryStore,
|
| 7 |
+
RedisRetryStore,
|
| 8 |
+
RetryEntry,
|
| 9 |
+
RetryQueue,
|
| 10 |
+
drain_retry_queue,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FakeRedis:
|
| 15 |
+
"""Minimal async Redis subset (hash + zset) used by RedisRetryStore."""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.hashes: dict[str, dict[str, str]] = {}
|
| 19 |
+
self.zsets: dict[str, dict[str, float]] = {}
|
| 20 |
+
|
| 21 |
+
async def hset(self, key, field, value):
|
| 22 |
+
self.hashes.setdefault(key, {})[field] = value
|
| 23 |
+
|
| 24 |
+
async def hget(self, key, field):
|
| 25 |
+
return self.hashes.get(key, {}).get(field)
|
| 26 |
+
|
| 27 |
+
async def hdel(self, key, field):
|
| 28 |
+
self.hashes.get(key, {}).pop(field, None)
|
| 29 |
+
|
| 30 |
+
async def zadd(self, key, mapping):
|
| 31 |
+
self.zsets.setdefault(key, {}).update(mapping)
|
| 32 |
+
|
| 33 |
+
async def zrem(self, key, member):
|
| 34 |
+
self.zsets.get(key, {}).pop(member, None)
|
| 35 |
+
|
| 36 |
+
async def zrangebyscore(self, key, min_, max_, start=0, num=None):
|
| 37 |
+
items = [
|
| 38 |
+
(m, s) for m, s in self.zsets.get(key, {}).items() if min_ <= s <= max_
|
| 39 |
+
]
|
| 40 |
+
items.sort(key=lambda kv: kv[1])
|
| 41 |
+
members = [m for m, _ in items]
|
| 42 |
+
return members[start : (start + num) if num else None]
|
| 43 |
+
|
| 44 |
+
async def zcard(self, key):
|
| 45 |
+
return len(self.zsets.get(key, {}))
|
| 46 |
+
|
| 47 |
+
async def hlen(self, key):
|
| 48 |
+
return len(self.hashes.get(key, {}))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class RedisRetryStoreTests(unittest.IsolatedAsyncioTestCase):
|
| 52 |
+
"""Production needs the queue to survive a restart and be shared across
|
| 53 |
+
workers — an in-process dict does neither."""
|
| 54 |
+
|
| 55 |
+
async def test_add_persists_entry(self):
|
| 56 |
+
store = RedisRetryStore(client=FakeRedis())
|
| 57 |
+
entry = RetryEntry(email="a@corp.test", mx_host="mx.corp.test", due_at=500.0)
|
| 58 |
+
await store.add(entry)
|
| 59 |
+
got = await store.get("a@corp.test")
|
| 60 |
+
self.assertIsNotNone(got)
|
| 61 |
+
self.assertEqual(got.mx_host, "mx.corp.test")
|
| 62 |
+
self.assertEqual(got.due_at, 500.0)
|
| 63 |
+
|
| 64 |
+
async def test_due_returns_only_matured_entries(self):
|
| 65 |
+
store = RedisRetryStore(client=FakeRedis())
|
| 66 |
+
await store.add(RetryEntry(email="early@corp.test", mx_host="mx", due_at=100.0))
|
| 67 |
+
await store.add(RetryEntry(email="late@corp.test", mx_host="mx", due_at=9999.0))
|
| 68 |
+
due = await store.due(now=200.0, limit=10)
|
| 69 |
+
self.assertEqual([e.email for e in due], ["early@corp.test"])
|
| 70 |
+
|
| 71 |
+
async def test_due_is_ordered_by_time(self):
|
| 72 |
+
store = RedisRetryStore(client=FakeRedis())
|
| 73 |
+
await store.add(RetryEntry(email="b@corp.test", mx_host="mx", due_at=300.0))
|
| 74 |
+
await store.add(RetryEntry(email="a@corp.test", mx_host="mx", due_at=200.0))
|
| 75 |
+
due = await store.due(now=400.0, limit=10)
|
| 76 |
+
self.assertEqual([e.email for e in due], ["a@corp.test", "b@corp.test"])
|
| 77 |
+
|
| 78 |
+
async def test_remove_clears_both_indexes(self):
|
| 79 |
+
redis = FakeRedis()
|
| 80 |
+
store = RedisRetryStore(client=redis)
|
| 81 |
+
await store.add(RetryEntry(email="a@corp.test", mx_host="mx", due_at=1.0))
|
| 82 |
+
await store.remove("a@corp.test")
|
| 83 |
+
self.assertIsNone(await store.get("a@corp.test"))
|
| 84 |
+
self.assertEqual(await store.count(), 0)
|
| 85 |
+
|
| 86 |
+
async def test_readd_updates_rather_than_duplicates(self):
|
| 87 |
+
store = RedisRetryStore(client=FakeRedis())
|
| 88 |
+
await store.add(RetryEntry(email="a@corp.test", mx_host="mx", due_at=1.0))
|
| 89 |
+
await store.add(RetryEntry(email="a@corp.test", mx_host="mx", due_at=99.0, attempts=1))
|
| 90 |
+
self.assertEqual(await store.count(), 1)
|
| 91 |
+
self.assertEqual((await store.get("a@corp.test")).attempts, 1)
|
| 92 |
+
|
| 93 |
+
async def test_corrupt_entry_is_skipped_not_raised(self):
|
| 94 |
+
redis = FakeRedis()
|
| 95 |
+
store = RedisRetryStore(client=redis)
|
| 96 |
+
await store.add(RetryEntry(email="ok@corp.test", mx_host="mx", due_at=1.0))
|
| 97 |
+
# Simulate a half-written/garbage payload for another member.
|
| 98 |
+
redis.hashes.setdefault("vx:retry:entries", {})["bad@corp.test"] = "{not json"
|
| 99 |
+
redis.zsets.setdefault("vx:retry:due", {})["bad@corp.test"] = 1.0
|
| 100 |
+
due = await store.due(now=5.0, limit=10)
|
| 101 |
+
self.assertEqual([e.email for e in due], ["ok@corp.test"])
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class DrainWorkerTests(unittest.IsolatedAsyncioTestCase):
|
| 105 |
+
"""A queue that only fills is a leak. Due entries must actually be retried,
|
| 106 |
+
and a definitive answer must clear the entry."""
|
| 107 |
+
|
| 108 |
+
async def test_drain_reprobes_due_entries_and_resolves_them(self):
|
| 109 |
+
clock = {"t": 0.0}
|
| 110 |
+
queue = RetryQueue(
|
| 111 |
+
store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"]
|
| 112 |
+
)
|
| 113 |
+
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 114 |
+
clock["t"] = 100.0
|
| 115 |
+
|
| 116 |
+
probed: list[tuple[str, str]] = []
|
| 117 |
+
|
| 118 |
+
async def prober(email, mx_host):
|
| 119 |
+
probed.append((email, mx_host))
|
| 120 |
+
return SMTPResult(verified=True, smtp_code=250)
|
| 121 |
+
|
| 122 |
+
stats = await drain_retry_queue(prober, queue=queue, limit=10)
|
| 123 |
+
|
| 124 |
+
self.assertEqual(probed, [("a@corp.test", "mx.corp.test")])
|
| 125 |
+
self.assertEqual(stats["resolved"], 1)
|
| 126 |
+
self.assertEqual(stats["requeued"], 0)
|
| 127 |
+
self.assertEqual(await queue.size(), 0)
|
| 128 |
+
|
| 129 |
+
async def test_still_greylisted_entry_is_requeued_with_backoff(self):
|
| 130 |
+
clock = {"t": 0.0}
|
| 131 |
+
queue = RetryQueue(
|
| 132 |
+
store=MemoryRetryStore(), backoff_seconds=10, max_attempts=3,
|
| 133 |
+
now=lambda: clock["t"],
|
| 134 |
+
)
|
| 135 |
+
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 136 |
+
clock["t"] = 100.0
|
| 137 |
+
|
| 138 |
+
async def prober(email, mx_host):
|
| 139 |
+
return SMTPResult(verified=None, smtp_code=451, smtp_message="still greylisted")
|
| 140 |
+
|
| 141 |
+
stats = await drain_retry_queue(prober, queue=queue, limit=10)
|
| 142 |
+
|
| 143 |
+
self.assertEqual(stats["requeued"], 1)
|
| 144 |
+
self.assertEqual(await queue.size(), 1)
|
| 145 |
+
# Backed off: not immediately due again.
|
| 146 |
+
self.assertEqual(await queue.due(), [])
|
| 147 |
+
|
| 148 |
+
async def test_rejected_entry_is_resolved_and_removed(self):
|
| 149 |
+
clock = {"t": 0.0}
|
| 150 |
+
queue = RetryQueue(store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"])
|
| 151 |
+
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 152 |
+
clock["t"] = 100.0
|
| 153 |
+
|
| 154 |
+
async def prober(email, mx_host):
|
| 155 |
+
return SMTPResult(verified=False, smtp_code=550, smtp_message="550 no such user")
|
| 156 |
+
|
| 157 |
+
stats = await drain_retry_queue(prober, queue=queue, limit=10)
|
| 158 |
+
|
| 159 |
+
self.assertEqual(stats["resolved"], 1)
|
| 160 |
+
self.assertEqual(await queue.size(), 0)
|
| 161 |
+
|
| 162 |
+
async def test_prober_error_requeues_without_crashing(self):
|
| 163 |
+
clock = {"t": 0.0}
|
| 164 |
+
queue = RetryQueue(store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"])
|
| 165 |
+
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 166 |
+
clock["t"] = 100.0
|
| 167 |
+
|
| 168 |
+
async def prober(email, mx_host):
|
| 169 |
+
raise RuntimeError("mx exploded")
|
| 170 |
+
|
| 171 |
+
stats = await drain_retry_queue(prober, queue=queue, limit=10)
|
| 172 |
+
|
| 173 |
+
self.assertEqual(stats["errors"], 1)
|
| 174 |
+
self.assertEqual(await queue.size(), 1, "must stay queued for another try")
|
| 175 |
+
|
| 176 |
+
async def test_empty_queue_is_a_noop(self):
|
| 177 |
+
queue = RetryQueue(store=MemoryRetryStore(), now=lambda: 0.0)
|
| 178 |
+
|
| 179 |
+
async def prober(email, mx_host):
|
| 180 |
+
raise AssertionError("should not be called")
|
| 181 |
+
|
| 182 |
+
stats = await drain_retry_queue(prober, queue=queue, limit=10)
|
| 183 |
+
self.assertEqual(
|
| 184 |
+
stats, {"checked": 0, "resolved": 0, "requeued": 0, "errors": 0, "results": []}
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
async def test_results_are_returned_for_persistence(self):
|
| 188 |
+
clock = {"t": 0.0}
|
| 189 |
+
queue = RetryQueue(store=MemoryRetryStore(), backoff_seconds=10, now=lambda: clock["t"])
|
| 190 |
+
await queue.enqueue("a@corp.test", "mx.corp.test", code=451)
|
| 191 |
+
clock["t"] = 100.0
|
| 192 |
+
|
| 193 |
+
async def prober(email, mx_host):
|
| 194 |
+
return SMTPResult(verified=True, smtp_code=250)
|
| 195 |
+
|
| 196 |
+
stats = await drain_retry_queue(prober, queue=queue, limit=10)
|
| 197 |
+
self.assertEqual(stats["results"][0][0], "a@corp.test")
|
| 198 |
+
self.assertTrue(stats["results"][0][1].verified)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
unittest.main()
|