File size: 11,468 Bytes
cd0c7a9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 | """
Thin client for NCBI BLAST URL API (QBLAST).
Rate limit: NCBI enforces 1 request per 10 seconds without an API key,
3 req/s with an API key. Rate limiting is the caller's responsibility.
API docs: https://ncbi.github.io/blast-cloud/api.html
"""
import asyncio
import logging
import os
import re
import httpx
logger = logging.getLogger(__name__)
NCBI_BLAST_URL = "https://blast.ncbi.nlm.nih.gov/blast/Blast.cgi"
RATE_LIMIT_SECONDS = 10
from app.config import settings
NCBI_API_KEY = settings.NCBI_API_KEY
def _api_key_param() -> dict:
"""Return {api_key: key} if configured, else empty dict."""
return {"api_key": NCBI_API_KEY} if NCBI_API_KEY else {}
async def _request_with_retry(method: str, url: str, max_retries: int = 3, request_timeout: float = 60.0, **kwargs) -> httpx.Response:
"""Make an HTTP request with retry on connection, timeout, and transient errors.
request_timeout is the read/write timeout. NCBI's synchronous mode blocks
until results are ready, so callers must pass a generous value for it.
"""
for attempt in range(max_retries):
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(request_timeout, connect=15.0)) as client:
resp = await getattr(client, method)(url, **kwargs)
resp.raise_for_status()
return resp
except (
httpx.ReadError,
httpx.RemoteProtocolError,
httpx.ConnectError,
httpx.TimeoutException,
httpx.HTTPStatusError,
) as e:
if attempt < max_retries - 1:
delay = 3 * (attempt + 1)
logger.warning("NCBI request failed (attempt %d/%d): %s — retrying in %ds", attempt + 1, max_retries, e, delay)
await asyncio.sleep(delay)
else:
raise
async def submit_blast(
sequence: str,
program: str = "blastp",
database: str = "nr",
hitlist_size: int = 100,
expect: float = 10.0,
gapopen: int = -1,
gapextend: int = -1,
matrix: str = "BLOSUM62",
async_flag: bool = True,
) -> dict:
params = {
"CMD": "Put",
"PROGRAM": program,
"DATABASE": database,
"QUERY": sequence,
"HITLIST_SIZE": str(hitlist_size),
"EXPECT": str(expect),
"MATRIX": matrix,
"ASYNC": "1" if async_flag else "0",
"EMAIL": settings.NCBI_EMAIL,
**_api_key_param(),
}
if gapopen > 0:
params["GAPOPEN"] = str(gapopen)
if gapextend > 0:
params["GAPEXTEND"] = str(gapextend)
resp = await _request_with_retry(
"post", NCBI_BLAST_URL, data=params,
request_timeout=300.0 if not async_flag else 60.0,
)
text = resp.text
rid_match = re.search(r"RID\s*=\s*(\S+)", text)
rtoe_match = re.search(r"RTOE\s*=\s*(\d+)", text)
if not rid_match:
return {"error": "No RID returned from NCBI", "raw": text[:500]}
rid = rid_match.group(1)
rtoe = int(rtoe_match.group(1)) if rtoe_match else 60
result = {"rid": rid, "estimated_seconds": rtoe}
if not async_flag and "Status=READY" in text:
# NCBI blocked and returned results inline in the submit response.
result["raw"] = text
return result
async def submit_blast_sync(sequence: str, **kwargs) -> dict:
"""Submit BLAST in synchronous (blocking) mode — NCBI returns results inline.
Used as fallback when async mode yields unreasonable RTOE or jobs get stuck.
"""
kwargs.pop("async_flag", None)
return await submit_blast(sequence, async_flag=False, **kwargs)
async def check_status(rid: str, fmt: str = "XML") -> dict:
params = {"CMD": "Get", "FORMAT_TYPE": fmt, "RID": rid, **_api_key_param()}
resp = await _request_with_retry("get", NCBI_BLAST_URL, params=params)
text = resp.text
if "Status=" in text:
status_match = re.search(r"Status\s*=\s*(\w+)", text)
status = status_match.group(1) if status_match else "UNKNOWN"
else:
status = "READY"
return {"status": status, "raw": text, "rid": rid}
async def fetch_results(rid: str, fmt: str = "XML") -> dict:
params = {"CMD": "Get", "FORMAT_TYPE": fmt, "RID": rid, **_api_key_param()}
resp = await _request_with_retry("get", NCBI_BLAST_URL, params=params)
text = resp.text
if "Status=" in text and "Status=READY" not in text:
return {"error": "Results not ready", "raw": text[:200]}
return {"raw": text, "rid": rid}
async def check_status_until_ready(
rid: str,
max_wait_seconds: int = 300,
estimated_seconds: int = 0,
) -> dict:
"""Poll NCBI with exponential backoff until READY or budget exhausted.
Starts at 10s delay (NCBI rate-limit guidance: 1 req/10s without API key),
backs off to 25s ceiling. Transient poll failures (timeouts, HTTP errors)
are tolerated up to 3 consecutive times before giving up.
If estimated_seconds is provided and the job stays in WAITING for more
than 5x that duration (minimum 60s), it's treated as stuck.
"""
elapsed = 0
# With an API key NCBI allows 3 req/s; without, 1 req/10s.
delay = 5 if NCBI_API_KEY else 10
consecutive_failures = 0
max_consecutive_failures = 3
# Stuck-job threshold: 5x the RTOE, but at least 180s (NCBI overload can
# push legitimate jobs well past their RTOE, so don't give up early).
stuck_threshold = max(estimated_seconds * 5, 180) if estimated_seconds > 0 else 240
while elapsed < max_wait_seconds:
try:
result = await check_status(rid)
consecutive_failures = 0 # reset on success
except Exception as e:
consecutive_failures += 1
logger.warning(
"BLAST poll for %s failed (consecutive %d/%d): %s",
rid, consecutive_failures, max_consecutive_failures, e,
)
if consecutive_failures >= max_consecutive_failures:
logger.warning(
"BLAST RID %s — %d consecutive poll failures, giving up", rid, consecutive_failures
)
return {"status": "POLL_FAILED", "rid": rid, "error": str(e)}
await asyncio.sleep(delay)
elapsed += delay
delay = min(delay * 1.5, 15 if NCBI_API_KEY else 25)
continue
status = result["status"]
if status == "READY":
return result
if status not in ("WAITING", "UNKNOWN", "QUEUED"):
# FAILED / ERROR — bail immediately
logger.warning("BLAST RID %s returned terminal status: %s", rid, status)
return result
# Stuck-job detection: if WAITING far beyond RTOE, job is likely stuck
if elapsed > stuck_threshold:
logger.warning(
"BLAST RID %s stuck in %s for %ds (threshold=%ds), treating as STUCK",
rid, status, elapsed, stuck_threshold,
)
return {"status": "STUCK", "rid": rid, "error": f"Job stuck in {status} for {elapsed}s"}
await asyncio.sleep(delay)
elapsed += delay
delay = min(delay * 1.5, 15 if NCBI_API_KEY else 25) # back off, cap at 15s (key) / 25s (no key)
logger.warning("BLAST RID %s timed out after %ds", rid, max_wait_seconds)
return {"status": "TIMEOUT", "rid": rid}
async def run_blast_with_retry(
sequence: str,
retries: int = 2,
max_wait_seconds: int = 600,
**submit_kwargs,
) -> dict:
"""Submit + poll + fetch with retries on timeout/failure.
If NCBI dropped/lost the RID, no amount of polling helps — a fresh
submit_blast() is the right fix. retries=2 means 3 total attempts.
Falls back to synchronous mode when async RTOE is unreasonable (>300s),
which indicates NCBI server overload. In sync mode NCBI blocks until
results are ready (up to the httpx timeout).
"""
last_error = None
MAX_RTOE = 300 # if RTOE exceeds this, switch to sync mode
for attempt in range(retries + 1):
# On retry after stuck/timeout, try sync mode first
use_sync = attempt > 0
try:
if use_sync:
logger.info("BLAST attempt %d/%d: trying synchronous mode", attempt + 1, retries + 1)
submit_result = await submit_blast_sync(sequence, **submit_kwargs)
else:
submit_result = await submit_blast(sequence, **submit_kwargs)
except Exception as e:
last_error = f"BLAST submit request failed: {e}"
logger.warning(
"BLAST submit threw (attempt %d/%d): %s",
attempt + 1, retries + 1, last_error,
)
if attempt < retries:
await asyncio.sleep(5 * (attempt + 1))
continue
if "error" in submit_result:
last_error = submit_result["error"]
logger.warning(
"BLAST submit failed (attempt %d/%d): %s",
attempt + 1, retries + 1, last_error,
)
if attempt < retries:
await asyncio.sleep(5 * (attempt + 1))
continue
rid = submit_result["rid"]
est = submit_result.get("estimated_seconds", 0)
logger.info(
"BLAST submitted (attempt %d/%d, sync=%s), RID=%s, est=%ds",
attempt + 1, retries + 1, use_sync, rid, est,
)
# Detect unreasonable RTOE — switch to sync on next attempt
if not use_sync and est > MAX_RTOE:
logger.warning(
"BLAST RTOE=%ds exceeds threshold (%ds), will use sync mode on retry", est, MAX_RTOE,
)
last_error = f"NCBI estimated {est}s queue time (threshold {MAX_RTOE}s)"
if attempt < retries:
await asyncio.sleep(2)
continue
if use_sync:
# Sync mode: NCBI blocked and returned the result inline in the
# submit response. If the raw XML came back ready, use it directly.
if submit_result.get("raw"):
logger.info("BLAST sync mode returned results inline for RID %s", rid)
return {"raw": submit_result["raw"], "rid": rid}
try:
status_result = await check_status_until_ready(
rid, max_wait_seconds=max_wait_seconds, estimated_seconds=est,
)
except Exception as e:
last_error = f"BLAST polling crashed: {e}"
logger.warning("BLAST RID %s: %s", rid, last_error)
if attempt < retries:
await asyncio.sleep(5 * (attempt + 1))
continue
if status_result["status"] == "READY":
try:
return await fetch_results(rid)
except Exception as e:
last_error = f"BLAST result fetch failed: {e}"
logger.warning("BLAST RID %s: %s", rid, last_error)
if attempt < retries:
await asyncio.sleep(5 * (attempt + 1))
continue
last_error = f"BLAST {status_result['status']} after polling (attempt {attempt + 1}/{retries + 1})"
if status_result.get("error"):
last_error += f": {status_result['error']}"
logger.warning("BLAST RID %s: %s", rid, last_error)
if attempt < retries:
await asyncio.sleep(5 * (attempt + 1))
return {"error": last_error or "BLAST failed after all attempts"}
|