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"}