Samad14 commited on
Commit
2dadeea
·
1 Parent(s): 9c0c10a

fix: harden BLAST pipeline against NCBI timeouts and server overload

Browse files

- Add EMAIL parameter to NCBI submit_blast (required by NCBI API)
- Catch httpx.TimeoutException and HTTPStatusError in _request_with_retry
- Add stuck-job detection in check_status_until_ready (5x RTOE threshold)
- Add synchronous mode fallback when async RTOE is unreasonable (>300s)
- Tolerate up to 3 consecutive poll failures before giving up
- Increase retries from 1 to 2 (3 total attempts)
- Increase poll start delay from 5s to 10s (NCBI rate limit guidance)
- Add QUEUED status handling in poll loop
- Fix EBI BlastTool: increase poll timeout (120->180s), add failure tolerance
- Pipeline v2: pass retries=2 to run_blast_with_retry
- Add comprehensive BLAST test suite (test_blast_comprehensive.py) with:
* Multiple protein datasets (crambin, insulin, GFP, lysozyme, hemoglobin)
* Full NCBI roundtrip tests, XML parsing, retry verification
* EBI BLAST tool tests, error handling (empty/invalid/long sequences)
* Random fuzz tests (5 random sequences), consecutive reliability tests
* Pipeline v2 integration loop test

bioai-platform/backend/app/integrations/ncbi/blast.py CHANGED
@@ -29,16 +29,22 @@ def _api_key_param() -> dict:
29
 
30
 
31
  async def _request_with_retry(method: str, url: str, max_retries: int = 3, **kwargs) -> httpx.Response:
32
- """Make an HTTP request with retry on connection errors."""
33
  for attempt in range(max_retries):
34
  try:
35
  async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=15.0)) as client:
36
  resp = await getattr(client, method)(url, **kwargs)
37
  resp.raise_for_status()
38
  return resp
39
- except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
 
 
 
 
 
 
40
  if attempt < max_retries - 1:
41
- delay = 2 * (attempt + 1)
42
  logger.warning("NCBI request failed (attempt %d/%d): %s — retrying in %ds", attempt + 1, max_retries, e, delay)
43
  await asyncio.sleep(delay)
44
  else:
@@ -65,6 +71,7 @@ async def submit_blast(
65
  "EXPECT": str(expect),
66
  "MATRIX": matrix,
67
  "ASYNC": "1" if async_flag else "0",
 
68
  **_api_key_param(),
69
  }
70
  if gapopen > 0:
@@ -87,6 +94,15 @@ async def submit_blast(
87
  return {"rid": rid, "estimated_seconds": rtoe}
88
 
89
 
 
 
 
 
 
 
 
 
 
90
  async def check_status(rid: str, fmt: str = "XML") -> dict:
91
  params = {"CMD": "Get", "FORMAT_TYPE": fmt, "RID": rid, **_api_key_param()}
92
  resp = await _request_with_retry("get", NCBI_BLAST_URL, params=params)
@@ -115,29 +131,64 @@ async def fetch_results(rid: str, fmt: str = "XML") -> dict:
115
  async def check_status_until_ready(
116
  rid: str,
117
  max_wait_seconds: int = 300,
 
118
  ) -> dict:
119
  """Poll NCBI with exponential backoff until READY or budget exhausted.
120
 
121
- Starts at 5s delay (NCBI guidance), backs off to 20s ceiling.
122
- Returns early on ERROR/FAILED — no point polling a dead RID.
 
 
 
 
123
  """
124
  elapsed = 0
125
- delay = 5 # start at 5s, matches NCBI's own guidance
 
 
 
 
126
 
127
  while elapsed < max_wait_seconds:
128
- result = await check_status(rid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  status = result["status"]
130
 
131
  if status == "READY":
132
  return result
133
- if status not in ("WAITING", "UNKNOWN"):
134
  # FAILED / ERROR — bail immediately
135
  logger.warning("BLAST RID %s returned terminal status: %s", rid, status)
136
  return result
137
 
 
 
 
 
 
 
 
 
138
  await asyncio.sleep(delay)
139
  elapsed += delay
140
- delay = min(delay * 1.5, 20) # back off, cap at 20s
141
 
142
  logger.warning("BLAST RID %s timed out after %ds", rid, max_wait_seconds)
143
  return {"status": "TIMEOUT", "rid": rid}
@@ -145,39 +196,101 @@ async def check_status_until_ready(
145
 
146
  async def run_blast_with_retry(
147
  sequence: str,
148
- retries: int = 1,
149
  max_wait_seconds: int = 600,
150
  **submit_kwargs,
151
  ) -> dict:
152
- """Submit + poll + fetch with one retry on timeout/failure.
153
 
154
  If NCBI dropped/lost the RID, no amount of polling helps — a fresh
155
- submit_blast() is the right fix. retries=1 means 2 total attempts.
 
 
 
 
156
  """
157
  last_error = None
 
158
 
159
  for attempt in range(retries + 1):
160
- submit_result = await submit_blast(sequence, **submit_kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  if "error" in submit_result:
162
  last_error = submit_result["error"]
163
  logger.warning(
164
  "BLAST submit failed (attempt %d/%d): %s",
165
  attempt + 1, retries + 1, last_error,
166
  )
 
 
167
  continue
168
 
169
  rid = submit_result["rid"]
170
  est = submit_result.get("estimated_seconds", 0)
171
  logger.info(
172
- "BLAST submitted (attempt %d/%d), RID=%s, est=%ds",
173
- attempt + 1, retries + 1, rid, est,
174
  )
175
 
176
- status_result = await check_status_until_ready(rid, max_wait_seconds=max_wait_seconds)
177
- if status_result["status"] == "READY":
178
- return await fetch_results(rid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
- last_error = f"BLAST {status_result['status']} after polling (attempt {attempt + 1})"
 
 
 
 
 
 
 
 
 
 
 
 
181
  logger.warning("BLAST RID %s: %s", rid, last_error)
 
 
182
 
183
  return {"error": last_error or "BLAST failed after all attempts"}
 
29
 
30
 
31
  async def _request_with_retry(method: str, url: str, max_retries: int = 3, **kwargs) -> httpx.Response:
32
+ """Make an HTTP request with retry on connection, timeout, and transient errors."""
33
  for attempt in range(max_retries):
34
  try:
35
  async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=15.0)) as client:
36
  resp = await getattr(client, method)(url, **kwargs)
37
  resp.raise_for_status()
38
  return resp
39
+ except (
40
+ httpx.ReadError,
41
+ httpx.RemoteProtocolError,
42
+ httpx.ConnectError,
43
+ httpx.TimeoutException,
44
+ httpx.HTTPStatusError,
45
+ ) as e:
46
  if attempt < max_retries - 1:
47
+ delay = 3 * (attempt + 1)
48
  logger.warning("NCBI request failed (attempt %d/%d): %s — retrying in %ds", attempt + 1, max_retries, e, delay)
49
  await asyncio.sleep(delay)
50
  else:
 
71
  "EXPECT": str(expect),
72
  "MATRIX": matrix,
73
  "ASYNC": "1" if async_flag else "0",
74
+ "EMAIL": settings.NCBI_EMAIL,
75
  **_api_key_param(),
76
  }
77
  if gapopen > 0:
 
94
  return {"rid": rid, "estimated_seconds": rtoe}
95
 
96
 
97
+ async def submit_blast_sync(sequence: str, **kwargs) -> dict:
98
+ """Submit BLAST in synchronous (blocking) mode — NCBI returns results inline.
99
+
100
+ Used as fallback when async mode yields unreasonable RTOE or jobs get stuck.
101
+ """
102
+ kwargs.pop("async_flag", None)
103
+ return await submit_blast(sequence, async_flag=False, **kwargs)
104
+
105
+
106
  async def check_status(rid: str, fmt: str = "XML") -> dict:
107
  params = {"CMD": "Get", "FORMAT_TYPE": fmt, "RID": rid, **_api_key_param()}
108
  resp = await _request_with_retry("get", NCBI_BLAST_URL, params=params)
 
131
  async def check_status_until_ready(
132
  rid: str,
133
  max_wait_seconds: int = 300,
134
+ estimated_seconds: int = 0,
135
  ) -> dict:
136
  """Poll NCBI with exponential backoff until READY or budget exhausted.
137
 
138
+ Starts at 10s delay (NCBI rate-limit guidance: 1 req/10s without API key),
139
+ backs off to 25s ceiling. Transient poll failures (timeouts, HTTP errors)
140
+ are tolerated up to 3 consecutive times before giving up.
141
+
142
+ If estimated_seconds is provided and the job stays in WAITING for more
143
+ than 5x that duration (minimum 60s), it's treated as stuck.
144
  """
145
  elapsed = 0
146
+ delay = 10 # NCBI rate limit: 1 req/10s without API key
147
+ consecutive_failures = 0
148
+ max_consecutive_failures = 3
149
+ # Stuck-job threshold: 5x the RTOE, but at least 60s
150
+ stuck_threshold = max(estimated_seconds * 5, 60) if estimated_seconds > 0 else 120
151
 
152
  while elapsed < max_wait_seconds:
153
+ try:
154
+ result = await check_status(rid)
155
+ consecutive_failures = 0 # reset on success
156
+ except Exception as e:
157
+ consecutive_failures += 1
158
+ logger.warning(
159
+ "BLAST poll for %s failed (consecutive %d/%d): %s",
160
+ rid, consecutive_failures, max_consecutive_failures, e,
161
+ )
162
+ if consecutive_failures >= max_consecutive_failures:
163
+ logger.warning(
164
+ "BLAST RID %s — %d consecutive poll failures, giving up", rid, consecutive_failures
165
+ )
166
+ return {"status": "POLL_FAILED", "rid": rid, "error": str(e)}
167
+ await asyncio.sleep(delay)
168
+ elapsed += delay
169
+ delay = min(delay * 1.5, 25)
170
+ continue
171
+
172
  status = result["status"]
173
 
174
  if status == "READY":
175
  return result
176
+ if status not in ("WAITING", "UNKNOWN", "QUEUED"):
177
  # FAILED / ERROR — bail immediately
178
  logger.warning("BLAST RID %s returned terminal status: %s", rid, status)
179
  return result
180
 
181
+ # Stuck-job detection: if WAITING far beyond RTOE, job is likely stuck
182
+ if elapsed > stuck_threshold:
183
+ logger.warning(
184
+ "BLAST RID %s stuck in %s for %ds (threshold=%ds), treating as STUCK",
185
+ rid, status, elapsed, stuck_threshold,
186
+ )
187
+ return {"status": "STUCK", "rid": rid, "error": f"Job stuck in {status} for {elapsed}s"}
188
+
189
  await asyncio.sleep(delay)
190
  elapsed += delay
191
+ delay = min(delay * 1.5, 25) # back off, cap at 25s
192
 
193
  logger.warning("BLAST RID %s timed out after %ds", rid, max_wait_seconds)
194
  return {"status": "TIMEOUT", "rid": rid}
 
196
 
197
  async def run_blast_with_retry(
198
  sequence: str,
199
+ retries: int = 2,
200
  max_wait_seconds: int = 600,
201
  **submit_kwargs,
202
  ) -> dict:
203
+ """Submit + poll + fetch with retries on timeout/failure.
204
 
205
  If NCBI dropped/lost the RID, no amount of polling helps — a fresh
206
+ submit_blast() is the right fix. retries=2 means 3 total attempts.
207
+
208
+ Falls back to synchronous mode when async RTOE is unreasonable (>300s),
209
+ which indicates NCBI server overload. In sync mode NCBI blocks until
210
+ results are ready (up to the httpx timeout).
211
  """
212
  last_error = None
213
+ MAX_RTOE = 300 # if RTOE exceeds this, switch to sync mode
214
 
215
  for attempt in range(retries + 1):
216
+ # On retry after stuck/timeout, try sync mode first
217
+ use_sync = attempt > 0
218
+
219
+ try:
220
+ if use_sync:
221
+ logger.info("BLAST attempt %d/%d: trying synchronous mode", attempt + 1, retries + 1)
222
+ submit_result = await submit_blast_sync(sequence, **submit_kwargs)
223
+ else:
224
+ submit_result = await submit_blast(sequence, **submit_kwargs)
225
+ except Exception as e:
226
+ last_error = f"BLAST submit request failed: {e}"
227
+ logger.warning(
228
+ "BLAST submit threw (attempt %d/%d): %s",
229
+ attempt + 1, retries + 1, last_error,
230
+ )
231
+ if attempt < retries:
232
+ await asyncio.sleep(5 * (attempt + 1))
233
+ continue
234
+
235
  if "error" in submit_result:
236
  last_error = submit_result["error"]
237
  logger.warning(
238
  "BLAST submit failed (attempt %d/%d): %s",
239
  attempt + 1, retries + 1, last_error,
240
  )
241
+ if attempt < retries:
242
+ await asyncio.sleep(5 * (attempt + 1))
243
  continue
244
 
245
  rid = submit_result["rid"]
246
  est = submit_result.get("estimated_seconds", 0)
247
  logger.info(
248
+ "BLAST submitted (attempt %d/%d, sync=%s), RID=%s, est=%ds",
249
+ attempt + 1, retries + 1, use_sync, rid, est,
250
  )
251
 
252
+ # Detect unreasonable RTOE — switch to sync on next attempt
253
+ if not use_sync and est > MAX_RTOE:
254
+ logger.warning(
255
+ "BLAST RTOE=%ds exceeds threshold (%ds), will use sync mode on retry", est, MAX_RTOE,
256
+ )
257
+ last_error = f"NCBI estimated {est}s queue time (threshold {MAX_RTOE}s)"
258
+ if attempt < retries:
259
+ await asyncio.sleep(2)
260
+ continue
261
+
262
+ if use_sync:
263
+ # Sync mode: NCBI blocked and returned the result inline in the submit response.
264
+ # The response text in submit_result may contain the XML already.
265
+ # We need to poll once to check if it's actually ready.
266
+ pass
267
+
268
+ try:
269
+ status_result = await check_status_until_ready(
270
+ rid, max_wait_seconds=max_wait_seconds, estimated_seconds=est,
271
+ )
272
+ except Exception as e:
273
+ last_error = f"BLAST polling crashed: {e}"
274
+ logger.warning("BLAST RID %s: %s", rid, last_error)
275
+ if attempt < retries:
276
+ await asyncio.sleep(5 * (attempt + 1))
277
+ continue
278
 
279
+ if status_result["status"] == "READY":
280
+ try:
281
+ return await fetch_results(rid)
282
+ except Exception as e:
283
+ last_error = f"BLAST result fetch failed: {e}"
284
+ logger.warning("BLAST RID %s: %s", rid, last_error)
285
+ if attempt < retries:
286
+ await asyncio.sleep(5 * (attempt + 1))
287
+ continue
288
+
289
+ last_error = f"BLAST {status_result['status']} after polling (attempt {attempt + 1}/{retries + 1})"
290
+ if status_result.get("error"):
291
+ last_error += f": {status_result['error']}"
292
  logger.warning("BLAST RID %s: %s", rid, last_error)
293
+ if attempt < retries:
294
+ await asyncio.sleep(5 * (attempt + 1))
295
 
296
  return {"error": last_error or "BLAST failed after all attempts"}
bioai-platform/backend/app/routers/pipeline_v2.py CHANGED
@@ -374,7 +374,7 @@ async def _run_blast(sequence: str, status_callback=None, fast_mode: bool = Fals
374
 
375
  results = await ncbi_blast.run_blast_with_retry(
376
  sequence,
377
- retries=1,
378
  max_wait_seconds=180 if fast_mode else 600,
379
  database=database,
380
  )
 
374
 
375
  results = await ncbi_blast.run_blast_with_retry(
376
  sequence,
377
+ retries=2,
378
  max_wait_seconds=180 if fast_mode else 600,
379
  database=database,
380
  )
bioai-platform/backend/app/tools/blast.py CHANGED
@@ -11,8 +11,8 @@ from app.services.cache import ttl_cache
11
  class BlastTool(BaseTool):
12
  name = "blast"
13
 
14
- POLL_INTERVAL = 2.0
15
- MAX_POLL_TIME = 120
16
 
17
  @ttl_cache(ttl=86400, prefix="blast")
18
  async def run(self, input: dict) -> dict:
@@ -42,14 +42,23 @@ class BlastTool(BaseTool):
42
 
43
  async def _poll(self, job_id: str) -> str:
44
  start = asyncio.get_event_loop().time()
45
- async with httpx.AsyncClient(timeout=10) as client:
 
46
  while True:
47
  elapsed = asyncio.get_event_loop().time() - start
48
  if elapsed > self.MAX_POLL_TIME:
49
  return "TIMEOUT"
50
- resp = await client.get(f"{settings.EBI_BASE_URL}/status/{job_id}")
51
- resp.raise_for_status()
52
- status = resp.text.strip()
 
 
 
 
 
 
 
 
53
  if status in ("FINISHED", "ERROR", "FAILED"):
54
  return status
55
  await asyncio.sleep(self.POLL_INTERVAL)
 
11
  class BlastTool(BaseTool):
12
  name = "blast"
13
 
14
+ POLL_INTERVAL = 3.0
15
+ MAX_POLL_TIME = 180
16
 
17
  @ttl_cache(ttl=86400, prefix="blast")
18
  async def run(self, input: dict) -> dict:
 
42
 
43
  async def _poll(self, job_id: str) -> str:
44
  start = asyncio.get_event_loop().time()
45
+ async with httpx.AsyncClient(timeout=15) as client:
46
+ consecutive_failures = 0
47
  while True:
48
  elapsed = asyncio.get_event_loop().time() - start
49
  if elapsed > self.MAX_POLL_TIME:
50
  return "TIMEOUT"
51
+ try:
52
+ resp = await client.get(f"{settings.EBI_BASE_URL}/status/{job_id}")
53
+ resp.raise_for_status()
54
+ status = resp.text.strip()
55
+ consecutive_failures = 0
56
+ except Exception as e:
57
+ consecutive_failures += 1
58
+ if consecutive_failures >= 5:
59
+ return "ERROR"
60
+ await asyncio.sleep(self.POLL_INTERVAL)
61
+ continue
62
  if status in ("FINISHED", "ERROR", "FAILED"):
63
  return status
64
  await asyncio.sleep(self.POLL_INTERVAL)
bioai-platform/backend/tests/test_blast_comprehensive.py ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Comprehensive BLAST test suite — loops over multiple datasets to verify
3
+ all BLAST features work end-to-end against live NCBI/EBI APIs.
4
+
5
+ Tests:
6
+ 1. NCBI BLAST integration (submit → poll → fetch)
7
+ 2. EBI BLAST tool (submit → poll → fetch)
8
+ 3. Multiple protein sequences of varying lengths
9
+ 4. Error handling (bad input, edge cases)
10
+ 5. Pipeline v2 BLAST step
11
+ 6. Retry logic verification
12
+
13
+ Run: pytest tests/test_blast_comprehensive.py -v -s --timeout=900
14
+ """
15
+
16
+ import asyncio
17
+ import random
18
+ import string
19
+ import pytest
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Test datasets — real protein sequences of varying lengths
23
+ # ---------------------------------------------------------------------------
24
+ BLAST_TEST_SEQUENCES = [
25
+ {
26
+ "name": "crambin_short",
27
+ "sequence": "TTCCPSIVARSNFNVCRLPG",
28
+ "description": "Crambin first 20 residues (20 aa)",
29
+ "expected_program": "blastp",
30
+ "min_hits": 1,
31
+ },
32
+ {
33
+ "name": "human_insulin",
34
+ "sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
35
+ "description": "Human insulin preproinsulin (110 aa)",
36
+ "expected_program": "blastp",
37
+ "min_hits": 1,
38
+ },
39
+ {
40
+ "name": "gfp_fragment",
41
+ "sequence": "MSKGEELFTGVVPILVELDGDVNGHKFSVSGEGEGDATYGKLTLKFICTTGKLPVPWPTLVTTFSYGVQCFSRYPDHMKQHDFFKSAMPEGYVQERTIFFKDDGNYKTRAEVKFEGDTLVNRIELKGIDFKEDGNILGHKLEYNYNSHNVYIMADKQKNGIKVNFKIRHNIEDGSVQLADHYQQNTPIGDGPVLLPDNHYLSTQSALSKDPNEKRDHMVLLEFVTAAGITHGMDELYK",
42
+ "description": "Green fluorescent protein (238 aa)",
43
+ "expected_program": "blastp",
44
+ "min_hits": 1,
45
+ },
46
+ {
47
+ "name": "lysozyme",
48
+ "sequence": "KVFGRCELAAAMKRHGLDNYRGYSLGNWVCAAKFESNFNTQATNRNTDGSTDYGILQINSRWWCNDGRTPGSRNLCNIPCSALLSSDITASVNCAKKIVSDGNGMNAWVAWRNRCKGTDVQAWIRGCRL",
49
+ "description": "Hen egg white lysozyme (129 aa)",
50
+ "expected_program": "blastp",
51
+ "min_hits": 1,
52
+ },
53
+ {
54
+ "name": "human_hemoglobin_beta",
55
+ "sequence": "MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH",
56
+ "description": "Human hemoglobin beta subunit (147 aa)",
57
+ "expected_program": "blastp",
58
+ "min_hits": 1,
59
+ },
60
+ ]
61
+
62
+ # DNA sequence for blastn testing
63
+ DNA_TEST_SEQUENCES = [
64
+ {
65
+ "name": "small_rna",
66
+ "sequence": "ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG",
67
+ "description": "Short DNA fragment (33 bp)",
68
+ "expected_program": "blastn",
69
+ "min_hits": 0, # DNA may or may not hit depending on db
70
+ },
71
+ ]
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # 1. NCBI BLAST integration — direct API tests
75
+ # ---------------------------------------------------------------------------
76
+ class TestNCBIBlastIntegration:
77
+ """Test the NCBI BLAST integration module directly (submit → poll → fetch)."""
78
+
79
+ @pytest.mark.asyncio
80
+ @pytest.mark.parametrize(
81
+ "dataset",
82
+ BLAST_TEST_SEQUENCES[:3], # First 3 for speed
83
+ ids=[d["name"] for d in BLAST_TEST_SEQUENCES[:3]],
84
+ )
85
+ async def test_ncbi_blast_submit_and_poll(self, dataset):
86
+ """Submit a BLAST job and poll until READY or TIMEOUT."""
87
+ from app.integrations.ncbi.blast import submit_blast, check_status_until_ready
88
+
89
+ seq = dataset["sequence"]
90
+ program = dataset["expected_program"]
91
+ db = "swissprot" # Use swissprot for faster results in tests
92
+
93
+ submit_result = await submit_blast(
94
+ seq, program=program, database=db, hitlist_size=10,
95
+ )
96
+
97
+ if "error" in submit_result:
98
+ # NCBI may rate-limit; skip if submit fails
99
+ pytest.skip(f"NCBI submit failed: {submit_result['error']}")
100
+
101
+ rid = submit_result["rid"]
102
+ assert rid, "RID should not be empty"
103
+ assert len(rid) > 5, f"RID looks too short: {rid}"
104
+
105
+ # Poll with generous timeout (2 min for tests)
106
+ status_result = await check_status_until_ready(rid, max_wait_seconds=120)
107
+ status = status_result.get("status", "UNKNOWN")
108
+ assert status in ("READY", "TIMEOUT", "POLL_FAILED", "ERROR", "FAILED"), \
109
+ f"Unexpected status: {status}"
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_ncbi_blast_full_roundtrip_short_seq(self):
113
+ """Full roundtrip: submit → poll → fetch results for crambin fragment."""
114
+ from app.integrations.ncbi.blast import run_blast_with_retry
115
+
116
+ result = await run_blast_with_retry(
117
+ "TTCCPSIVARSNFNVCRLPG",
118
+ retries=2,
119
+ max_wait_seconds=180,
120
+ program="blastp",
121
+ database="swissprot",
122
+ hitlist_size=10,
123
+ )
124
+
125
+ if "error" in result:
126
+ pytest.skip(f"NCBI BLAST roundtrip failed (external API issue): {result['error']}")
127
+
128
+ assert "raw" in result, "Should return raw XML"
129
+ assert len(result["raw"]) > 100, "Raw XML too short"
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_ncbi_blast_parse_xml(self):
133
+ """Verify XML parsing of NCBI BLAST results."""
134
+ from app.integrations.ncbi.blast import run_blast_with_retry
135
+ from app.integrations.ncbi.parser import parse_blast_xml
136
+
137
+ result = await run_blast_with_retry(
138
+ "MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH",
139
+ retries=2,
140
+ max_wait_seconds=180,
141
+ program="blastp",
142
+ database="swissprot",
143
+ hitlist_size=10,
144
+ )
145
+
146
+ if "error" in result:
147
+ pytest.skip(f"BLAST failed: {result['error']}")
148
+
149
+ parsed = parse_blast_xml(result["raw"])
150
+ assert "error" not in parsed, f"Parse error: {parsed.get('error')}"
151
+ assert parsed["count"] > 0, "Should find at least one hit"
152
+ assert parsed["query_length"] > 0, "Query length should be positive"
153
+
154
+ hit = parsed["hits"][0]
155
+ assert hit["accession"], "Hit should have accession"
156
+ assert hit["evalue"] >= 0, "E-value should be non-negative"
157
+ assert hit["bit_score"] > 0, "Bit score should be positive"
158
+
159
+ @pytest.mark.asyncio
160
+ async def test_ncbi_blast_retry_on_failure(self):
161
+ """Verify that retry logic works — submitting with invalid sequence should fail gracefully."""
162
+ from app.integrations.ncbi.blast import run_blast_with_retry
163
+
164
+ # Submit with clearly invalid sequence (too short / garbage)
165
+ result = await run_blast_with_retry(
166
+ "XXXX",
167
+ retries=1,
168
+ max_wait_seconds=30,
169
+ program="blastp",
170
+ database="swissprot",
171
+ )
172
+
173
+ # Should either get an error or no hits — both are acceptable
174
+ # The key is it shouldn't crash with an unhandled exception
175
+ assert isinstance(result, dict), "Result should be a dict"
176
+
177
+ @pytest.mark.asyncio
178
+ async def test_ncbi_blast_timeout_handling(self):
179
+ """Verify timeout returns proper error, not an unhandled exception."""
180
+ from app.integrations.ncbi.blast import run_blast_with_retry
181
+
182
+ result = await run_blast_with_retry(
183
+ "TTCCPSIVARSNFNVCRLPG",
184
+ retries=0,
185
+ max_wait_seconds=5, # Very short timeout — will almost certainly time out
186
+ program="blastp",
187
+ database="nr", # nr is slower than swissprot
188
+ )
189
+
190
+ # Should return a dict with error or hits — never crash
191
+ assert isinstance(result, dict), "Should return dict even on timeout"
192
+ # Either it finished very fast (unlikely) or it timed out gracefully
193
+ assert "error" in result or "raw" in result
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # 2. EBI BLAST tool tests
198
+ # ---------------------------------------------------------------------------
199
+ class TestEBIBlastTool:
200
+ """Test the EBI BLAST tool (BlastTool class)."""
201
+
202
+ @pytest.mark.asyncio
203
+ @pytest.mark.parametrize(
204
+ "dataset",
205
+ BLAST_TEST_SEQUENCES[:2],
206
+ ids=[d["name"] for d in BLAST_TEST_SEQUENCES[:2]],
207
+ )
208
+ async def test_ebi_blast_returns_hits(self, dataset):
209
+ """EBI BLAST should find hits for known proteins."""
210
+ from app.tools.blast import BlastTool
211
+
212
+ tool = BlastTool()
213
+ result = await tool.run({
214
+ "sequence": dataset["sequence"],
215
+ "program": "blastp",
216
+ "database": "uniprotkb_swissprot",
217
+ "max_hits": 5,
218
+ })
219
+
220
+ assert "hits" in result or "error" in result
221
+ if "hits" in result:
222
+ assert len(result["hits"]) > 0, "Should find at least one hit"
223
+ assert result["count"] == len(result["hits"])
224
+
225
+ @pytest.mark.asyncio
226
+ async def test_ebi_blast_hit_structure(self):
227
+ """Verify BLAST hit data structure is correct."""
228
+ from app.tools.blast import BlastTool
229
+
230
+ tool = BlastTool()
231
+ result = await tool.run({
232
+ "sequence": "TTCCPSIVARSNFNVCRLPG",
233
+ "program": "blastp",
234
+ "database": "uniprotkb_swissprot",
235
+ "max_hits": 3,
236
+ })
237
+
238
+ if "error" in result:
239
+ pytest.skip(f"EBI BLAST failed: {result['error']}")
240
+
241
+ for hit in result["hits"]:
242
+ assert "accession" in hit
243
+ assert "evalue" in hit
244
+ assert "bit_score" in hit
245
+ assert "identity_pct" in hit
246
+ assert hit["evalue"] < 1.0, f"E-value too high: {hit['evalue']}"
247
+
248
+ @pytest.mark.asyncio
249
+ async def test_ebi_blast_poll_resilience(self):
250
+ """Verify EBI BLAST poll handles transient failures."""
251
+ from app.tools.blast import BlastTool
252
+
253
+ tool = BlastTool()
254
+ # This tests the enhanced poll with failure tolerance
255
+ result = await tool.run({
256
+ "sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
257
+ "program": "blastp",
258
+ "database": "uniprotkb_swissprot",
259
+ "max_hits": 5,
260
+ })
261
+
262
+ # Should not crash — either hits or graceful error
263
+ assert isinstance(result, dict)
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # 3. Pipeline v2 BLAST step integration
268
+ # ---------------------------------------------------------------------------
269
+ class TestPipelineV2BLAST:
270
+ """Test BLAST as part of the pipeline v2 flow."""
271
+
272
+ @pytest.mark.asyncio
273
+ async def test_pipeline_blast_step_only(self):
274
+ """Run only the BLAST step via pipeline_v2."""
275
+ from app.integrations.ncbi.blast import run_blast_with_retry
276
+ from app.integrations.ncbi.parser import parse_blast_xml
277
+
278
+ # Simulate what pipeline_v2._run_blast does
279
+ sequence = "TTCCPSIVARSNFNVCRLPG"
280
+ database = "swissprot"
281
+
282
+ results = await run_blast_with_retry(
283
+ sequence,
284
+ retries=2,
285
+ max_wait_seconds=180,
286
+ database=database,
287
+ )
288
+
289
+ if "error" in results:
290
+ pytest.skip(f"Pipeline BLAST step failed: {results['error']}")
291
+
292
+ parsed = parse_blast_xml(results["raw"])
293
+ assert "error" not in parsed
294
+ assert parsed["count"] > 0
295
+
296
+ hits = parsed.get("hits", [])
297
+ top_hit = hits[0] if hits else None
298
+
299
+ # Verify the data structure matches what pipeline_v2 expects
300
+ if top_hit:
301
+ assert "accession" in top_hit
302
+ assert "evalue" in top_hit
303
+ assert "identity_pct" in top_hit
304
+ assert "bit_score" in top_hit
305
+
306
+ @pytest.mark.asyncio
307
+ async def test_pipeline_blast_multiple_sequences_loop(self):
308
+ """Loop: run BLAST for each test sequence and verify results."""
309
+ from app.integrations.ncbi.blast import run_blast_with_retry
310
+ from app.integrations.ncbi.parser import parse_blast_xml
311
+
312
+ results_summary = []
313
+
314
+ for dataset in BLAST_TEST_SEQUENCES:
315
+ sequence = dataset["sequence"]
316
+ name = dataset["name"]
317
+
318
+ try:
319
+ blast_result = await run_blast_with_retry(
320
+ sequence,
321
+ retries=2,
322
+ max_wait_seconds=180,
323
+ program=dataset["expected_program"],
324
+ database="swissprot",
325
+ hitlist_size=10,
326
+ )
327
+
328
+ if "error" in blast_result:
329
+ results_summary.append({
330
+ "name": name,
331
+ "status": "error",
332
+ "error": blast_result["error"],
333
+ })
334
+ continue
335
+
336
+ parsed = parse_blast_xml(blast_result["raw"])
337
+ hit_count = parsed.get("count", 0)
338
+ results_summary.append({
339
+ "name": name,
340
+ "status": "ok" if hit_count >= dataset["min_hits"] else "low_hits",
341
+ "hits": hit_count,
342
+ })
343
+
344
+ except Exception as e:
345
+ results_summary.append({
346
+ "name": name,
347
+ "status": "exception",
348
+ "error": str(e),
349
+ })
350
+
351
+ # Rate limit: wait between requests
352
+ await asyncio.sleep(2)
353
+
354
+ # Report results
355
+ print("\n=== BLAST Loop Test Results ===")
356
+ for r in results_summary:
357
+ print(f" {r['name']}: {r['status']} (hits={r.get('hits', 'N/A')}, error={r.get('error', 'none')})")
358
+
359
+ # At least some should succeed (unless NCBI is completely down)
360
+ ok_count = sum(1 for r in results_summary if r["status"] == "ok")
361
+ assert ok_count >= 1, f"No BLAST tests succeeded: {results_summary}"
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # 4. Error handling & edge cases
366
+ # ---------------------------------------------------------------------------
367
+ class TestBlastErrorHandling:
368
+ """Test BLAST error handling with bad inputs."""
369
+
370
+ @pytest.mark.asyncio
371
+ async def test_empty_sequence(self):
372
+ """BLAST should handle empty sequence gracefully."""
373
+ from app.integrations.ncbi.blast import run_blast_with_retry
374
+
375
+ result = await run_blast_with_retry(
376
+ "",
377
+ retries=0,
378
+ max_wait_seconds=10,
379
+ database="swissprot",
380
+ )
381
+
382
+ assert isinstance(result, dict)
383
+ assert "error" in result or "raw" in result
384
+
385
+ @pytest.mark.asyncio
386
+ async def test_invalid_characters(self):
387
+ """BLAST should handle non-biological characters."""
388
+ from app.integrations.ncbi.blast import run_blast_with_retry
389
+
390
+ result = await run_blast_with_retry(
391
+ "12345!@#$%^&*()",
392
+ retries=0,
393
+ max_wait_seconds=30,
394
+ program="blastp",
395
+ database="swissprot",
396
+ )
397
+
398
+ assert isinstance(result, dict)
399
+
400
+ @pytest.mark.asyncio
401
+ async def test_very_long_sequence(self):
402
+ """BLAST should handle long sequences (may be slow but shouldn't crash)."""
403
+ from app.integrations.ncbi.blast import run_blast_with_retry
404
+
405
+ # Generate a 500 aa random protein sequence
406
+ aa_chars = "ACDEFGHIKLMNPQRSTVWY"
407
+ long_seq = "".join(random.choice(aa_chars) for _ in range(500))
408
+
409
+ result = await run_blast_with_retry(
410
+ long_seq,
411
+ retries=1,
412
+ max_wait_seconds=120,
413
+ program="blastp",
414
+ database="swissprot",
415
+ )
416
+
417
+ assert isinstance(result, dict)
418
+
419
+ @pytest.mark.asyncio
420
+ async def test_dna_blastn(self):
421
+ """Test blastn with a short DNA sequence."""
422
+ from app.integrations.ncbi.blast import run_blast_with_retry
423
+
424
+ result = await run_blast_with_retry(
425
+ "ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG",
426
+ retries=1,
427
+ max_wait_seconds=120,
428
+ program="blastn",
429
+ database="nt",
430
+ hitlist_size=5,
431
+ )
432
+
433
+ assert isinstance(result, dict)
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # 5. Random data fuzz testing
438
+ # ---------------------------------------------------------------------------
439
+ class TestBlastRandomFuzz:
440
+ """Fuzz test: BLAST with random sequences to verify no crashes."""
441
+
442
+ @pytest.mark.asyncio
443
+ @pytest.mark.parametrize("i", range(5))
444
+ async def test_random_protein_sequence(self, i):
445
+ """Submit 5 random protein sequences — none should crash."""
446
+ from app.integrations.ncbi.blast import run_blast_with_retry
447
+
448
+ aa_chars = "ACDEFGHIKLMNPQRSTVWY"
449
+ length = random.randint(10, 80)
450
+ random_seq = "".join(random.choice(aa_chars) for _ in range(length))
451
+
452
+ result = await run_blast_with_retry(
453
+ random_seq,
454
+ retries=1,
455
+ max_wait_seconds=90,
456
+ program="blastp",
457
+ database="swissprot",
458
+ hitlist_size=5,
459
+ )
460
+
461
+ # Must return a dict — no unhandled exceptions
462
+ assert isinstance(result, dict), f"BLAST crashed on random seq: {random_seq}"
463
+ # Should have either error or raw (results)
464
+ assert "error" in result or "raw" in result
465
+
466
+ @pytest.mark.asyncio
467
+ async def test_random_edge_case_lengths(self):
468
+ """Test BLAST with edge-case sequence lengths."""
469
+ from app.integrations.ncbi.blast import run_blast_with_retry
470
+
471
+ aa_chars = "ACDEFGHIKLMNPQRSTVWY"
472
+ edge_cases = [
473
+ ("min_valid", "".join(random.choice(aa_chars) for _ in range(6))), # minimum
474
+ ("medium", "".join(random.choice(aa_chars) for _ in range(50))),
475
+ ("long", "".join(random.choice(aa_chars) for _ in range(200))),
476
+ ]
477
+
478
+ for name, seq in edge_cases:
479
+ result = await run_blast_with_retry(
480
+ seq,
481
+ retries=1,
482
+ max_wait_seconds=90,
483
+ program="blastp",
484
+ database="swissprot",
485
+ hitlist_size=5,
486
+ )
487
+ assert isinstance(result, dict), f"BLAST crashed on {name} ({len(seq)} aa)"
488
+ await asyncio.sleep(2)
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # 6. Consecutive reliability test
493
+ # ---------------------------------------------------------------------------
494
+ class TestBlastReliability:
495
+ """Run BLAST multiple times in sequence to verify consistent behavior."""
496
+
497
+ @pytest.mark.asyncio
498
+ async def test_consecutive_blast_runs(self):
499
+ """Run BLAST 3 times with the same sequence — should all succeed or all fail consistently."""
500
+ from app.integrations.ncbi.blast import run_blast_with_retry
501
+ from app.integrations.ncbi.parser import parse_blast_xml
502
+
503
+ sequence = "TTCCPSIVARSNFNVCRLPG"
504
+ results = []
505
+
506
+ for i in range(3):
507
+ result = await run_blast_with_retry(
508
+ sequence,
509
+ retries=2,
510
+ max_wait_seconds=120,
511
+ program="blastp",
512
+ database="swissprot",
513
+ hitlist_size=5,
514
+ )
515
+ results.append(result)
516
+ await asyncio.sleep(3)
517
+
518
+ # Count successes vs failures
519
+ successes = sum(1 for r in results if "raw" in r)
520
+ failures = sum(1 for r in results if "error" in r)
521
+
522
+ print(f"\n=== Reliability: {successes} successes, {failures} failures out of 3 ===")
523
+
524
+ # At least 2/3 should succeed (NCBI occasional hiccups are OK)
525
+ assert successes >= 2, f"Too many BLAST failures: {failures}/3 failed"