sitcod3 commited on
Commit
58d0e85
·
verified ·
1 Parent(s): fabbde1

Upload folder using huggingface_hub

Browse files
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()