Skip to content

Replace the blocking blastp -remote call with NCBI's BLAST URL API and a bounded timeout - #102

Closed
mrubash1 wants to merge 13 commits into
Arcadia-Science:mainfrom
mrubash1:mr/blast-url-api
Closed

Replace the blocking blastp -remote call with NCBI's BLAST URL API and a bounded timeout#102
mrubash1 wants to merge 13 commits into
Arcadia-Science:mainfrom
mrubash1:mr/blast-url-api

Conversation

@mrubash1

@mrubash1 mrubash1 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Stacked on #101 (which is stacked on #100). The two commits new to this PR are the last two; review from Replace blastp -remote with NCBI's URL API onward.

The problem

run_blast shells out to blastp -remote, which blocks indefinitely polling NCBI's shared public queue. Observed during this work: 0.31 s of CPU over 51 minutes, no open socket, and a 0-byte output file. Reproduced identically on macOS arm64 and on x86-64 Linux, so it is not a local or an architecture problem.

The cause is queue congestion, not the pipeline's arguments. NCBI's own time-to-completion estimate (RTOE) came back at 7838 s (131 minutes) for a single 375-residue actin query. -word_size, -outfmt, -max_target_seqs, and shell=True were each ruled out by probing RTOE against a control: the same parameters submitted six times consecutively returned RTOE=20 every time, versus 8655 for those same parameters minutes earlier. RTOE tracks global server load, not the query.

There is no way to bound how long blastp -remote will block. That is what this PR changes.

The change

run_blast now drives NCBI's BLAST URL API with requests (already a dependency of envs/blast.yml), keeping the nr database and the existing retry loop:

  • SubmitCMD=Put with PROGRAM=blastp, DATABASE=nr, mapping max_target_seqsHITLIST_SIZE, word_sizeWORD_SIZE, evalueEXPECT, plus tool and email so NCBI can identify the client.
  • PollCMD=Get&FORMAT_OBJECT=SearchInfo, first after the returned RTOE, then no more often than every 60 seconds, as NCBI asks.
  • FetchCMD=Get&FORMAT_TYPE=JSON2_S, converted to the pipeline's existing 17-column -outfmt 6 layout.

run_blast still returns a CompletedProcess whose stderr is prefixed Error: on failure, so blast_call_failed() and the word-size backoff added in #101 are unchanged.

Why JSON2_S rather than Tabular

FORMAT_TYPE=Tabular would need no conversion, but NCBI does not actually deliver it. Fetching a completed search (RID 7TJ2P2DR014) returns a 62-byte body containing only the status block and no hits, with or without FORMAT_OBJECT=Alignment. The same RID returns real results in every other format:

Tabular  :      62 bytes (status block only)
Text     : 157,935 bytes
XML2_S   : 511,934 bytes
JSON2_S  : 422,458 bytes

JSON2_S also carries the subject accession and the taxonomy id directly, so staxids is populated rather than dropped.

Timeout

blast_timeout_seconds (default 1800, i.e. 30 minutes) bounds the total time spent on one protein across all retry attempts, not each attempt: a single deadline is established before the retry loop and each attempt receives only what remains. A retry cannot reset the budget. On expiry the search fails with a message reporting how long it waited and what NCBI estimated.

The timeout bounds the waiting, not the requests: a poll already in flight when it expires runs to completion, so a call can overrun by up to one HTTP_TIMEOUT_SECONDS (60s). An earlier revision of this description claimed the timeout was honoured exactly, which was wrong.

Database

blast_database config parameter, defaulting to nr — what the pipeline has always searched, so no existing run changes. It is a config parameter rather than a constant because which database is searched decides which homologs are found, so it belongs with the other parameters that determine a map's contents.

The documented alternative is nr_cluster_seq (ClusteredNR), not one of the RefSeq-only databases: it is built by clustering nr, so it does not drop the GenBank CDS translations, PDB, PIR and PRF entries that refseq_protein excludes outright. Measured against NCBI on 2026-08-13, nr and refseq_protein received identical time estimates and refseq_protein finished no faster, so the smaller RefSeq databases do not appear to buy the speed they are usually chosen for.

Failure handling

Three failure modes that this PR originally got wrong, each now covered by a test that fails without its fix:

  • A search whose estimate exceeded the budget was never polled at all. The deadline was re-checked between waiting and polling, so with the default 1800s timeout and a measured RTOE of 9168s the code slept the full 30 minutes, sent zero requests, and reported a timeout — for a search NCBI may well have already finished. The status is now always checked after waiting.
  • A poll that failed to reach NCBI ended the search. One refused connection discarded the request id and everything already spent waiting, and the retry submitted a fresh search to the back of the queue. Poll failures are now retried, bounded by MAX_CONSECUTIVE_POLL_FAILURES and by the timeout. The search is queued on NCBI's side, so failing to ask about it says nothing about the search.
  • An unusable results response was written as an empty results file. A report whose BlastOutput2 held no search, or a body that parsed as JSON but was not an object, produced an empty file and rc=0 — resurfacing downstream as the misleading "no hits were returned" that this PR exists to remove. Both are now reported as failures.

Other

  • blast_email config parameter, overridable with PROTEINCARTOGRAPHY_BLAST_EMAIL. The default is a project address, not an individual's.
  • resources: ncbi_remote=1 on the run_blast rule. Inert unless snakemake is given a budget for it (--resources ncbi_remote=1), which serializes BLAST so that a run with many input proteins does not submit concurrent searches to a shared queue. Documented in the README rather than forced on.

Output field fidelity

Converting the real JSON response reproduces the real blastp -remote row for a hit that both outputs share — 16 of 17 fields identical:

field                                                              match
qseqid, sseqid, pident, length, mismatch, gapopen                  OK
qstart, qend, sstart, send, evalue                                 OK
sacc, saccver, sgi, staxids, scomnames                             OK
bitscore                788 vs 787                                 differs

bitscore differs by less than 1 because the two searches ran against different nr snapshots, and the bit score depends on the effective search space. The test asserts this rather than hiding it.

scomnames is written as N/A. NCBI's JSON provides sciname (a scientific name), and the column is defined as the common name, so populating it would mislabel the data. sgi is written as 0, matching what the CLI writes for retired GIs. Only sacc is read downstream (extract_blast_hits.py:50).

What is and is not verified

Verified against live NCBI, end to end in a single run — on macOS arm64 and, independently, on x86-64 Linux (Modal):

Attempt 1/1 to run a blast search (using a word size of 5, with 14400s of the 14400s budget remaining)
NCBI queued the blast search as '7VPVAHA6014' and estimated that it will take 20s
RUN_BLAST_EXIT=0                                          # 84 seconds, macOS arm64

NCBI queued the blast search as '7VRWRVHH014' and estimated that it will take 31s
The blast search '7VRWRVHH014' is READY after 91s         # 93 seconds, x86-64 Linux
  • Submit, RID/RTOE parsing, polling, fetch, and conversion, all in one invocation
  • 10 hits written, every row with all 17 fields; staxids populated (89673, 61221, 9764, ...)
  • The repo's own extract_blast_hits.py run against that output returns 10 accessions, several of which (RLW01512, XP_007129366, NP_001009784) match the accessions in the repo's committed id-mapping fixture
  • The timeout path, verified separately: a search whose queue estimate was 7838 s failed at the configured deadline with a clear message rather than blocking
  • Gapped alignments: a 500-hit search produced 5 rows with gapopen > 0 and 444 rows with mismatches, so the gap-open counting and the mismatch calculation are exercised against real NCBI output. Note the repo's committed blastresults.tsv artifact contains no gapped rows at all, so this is broader coverage than that fixture provides.

Not verified against live NCBI:

  • Non-zero e-value formatting. Every hit in every real response obtained here had evalue=0, and the committed CLI artifact likewise contains only 0.0, so there is no real-world reference to compare against. format_evalue implements blastp's formatting thresholds and is unit-tested, but only against synthetic values. Note that evalue is not read downstream; only sacc is (extract_blast_hits.py:50), and that is verified above.

Test results

Run on macOS arm64 and independently on x86-64 Linux (Modal), both with CI's pinned tool versions:

74 passed, 3 warnings in 124.34s              # make test
26 of 26 steps (100%) done, 1 passed          # make smoke-test
ruff check .                                  # clean
ruff format --check .                         # 43 files left unchanged
snakefmt --check .                            # All 1 file(s) would be left unchanged

The 3 warnings are pre-existing deprecation notices from snakemake and stopit, not from this PR.

Unit tests mock every HTTP request and fake the clock, so multi-hour timeouts are exercised instantly and without sleeping. A trimmed 9.2 KB excerpt of a real NCBI response is committed as a fixture so the parser is tested against genuine output rather than only hand-written mocks; hits and descriptions were dropped, never edited.

Note for reviewers

This does not make BLAST faster. NCBI's queue is the bottleneck and remains so. What changes is that the pipeline waits a bounded, configurable amount of time and then fails with a clear message, instead of blocking indefinitely.

NCBI's congestion is intermittent, not permanent: the RTOE estimate for the same query was 7838 s on one day and 20 s the next. blastp -remote would therefore often work fine. The point of this change is the days when it does not, where it currently blocks for hours with no bound.

Alternative back ends were benchmarked (EBI's REST service returned 1000 hits against UniProtKB in ~8.5 minutes while NCBI returned nothing in any configuration) but deliberately not adopted: EBI has no nr, caps at 1000 hits against a configured max_blast_hits: 3000, and returns UniProt rather than RefSeq accessions. Changing which database is searched changes the results, which is a scientific decision rather than an engineering one.

mrubash1 and others added 13 commits August 13, 2026 16:28
`envs/analysis.yml` pinned its seven direct dependencies but nothing
transitive, so resolving it today installs versions of matplotlib and
setuptools that are incompatible with its pinned numpy 1.23.5, and two
rules fail:

    rule leiden_clustering (scanpy -> matplotlib):
      ImportError: Matplotlib requires numpy>=1.25; you have 1.23.5

    rule dim_reduction (umap-learn 0.5.3):
      ModuleNotFoundError: No module named 'pkg_resources'

matplotlib >= 3.8 requires numpy >= 1.25, and setuptools >= 81 removes
`pkg_resources`, which umap-learn 0.5.3 imports at module scope. The
matplotlib pin matches the one already used in `plotting.yml` and
`cartography_pub.yml`.

This is not platform-specific: a fresh solve produced matplotlib 3.11.0
on osx-64 and 3.9.4 on linux-64, and the pipeline failed identically on
both. Verified by running the cluster-mode demo end to end, where all
rules now complete and every final_results output is produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
`restore-keys` entries are matched as a prefix of `key`, but this one
began with "snakemake-conda-" while the key begins with "conda-", so it
could never match and the fallback restore never fired.

Note that this cache is also why CI did not catch the dependency drift
fixed in the previous commit: the key includes `hashFiles('envs/*.yml')`,
so as long as the env files are unchanged, CI restores a previously
solved set of conda envs and never re-resolves them against the current
package index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
The `run_blast` rule calls `blastp -remote`, which queues on NCBI's
public servers. Those queues are frequently long (NCBI's own RTOE
estimate was observed at ~2.5 hours), so running the pipeline end to end
was impractical for a quick sanity check or for CI.

`run_blast` now copies a canned results file when the env variable
`PROTEINCARTOGRAPHY_BLAST_STUB_RESULTS_FILEPATH` is set. An env variable
is used, rather than a CLI flag, because snakemake rule environments
inherit their env variables from the calling process, so neither the
Snakefile nor the pipeline config needs to change.

This replaces `tests.mocks.mock_run_blast`, which patched
`blast_utils.run_blast` and so required `run_blast.py` to import the
`tests` package. That import is removed here, extending the fix in an
earlier commit: importing `tests.mocks` calls `find_repo_dirpath()`,
which made the module unusable outside a git working tree.

`make smoke-test` runs the search-mode pipeline test with the stub. That
test also now asserts on snakemake's return value, which was previously
discarded, so the test could pass even when the pipeline failed, and it
checks that the output files are non-empty rather than merely present.

Verified by running the smoke test: 26 of 26 rules complete, in about
two minutes once the conda envs exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
When the remote server refuses to queue a request, `blastp -remote`
reports the error on stderr, writes an empty results file, and still
exits with a status of zero:

    $ blastp -remote -db nr ... ; echo $?
    Error: [blastp] bad_request: Could not queue request: DB operation
    failed.(ERR:-99  )((Severe Error) DB Put Request error:
    sp_NewRequestEx failed)
    0

`run_blast.py` branched on `result.returncode == 0`, so it treated these
failures as successes. The word-size backoff was therefore never
attempted, which is the situation it was added for, and the empty
results file was only reported later by `extract_blast_hits.py` as a
misleading "no hits were returned" error.

Failure is now determined by `blast_call_failed`, which also treats an
error on stderr as a failure. An empty results file is deliberately not
treated as a failure, because a query that legitimately has no hits
produces one too.

Note that this does not make the remote call any faster: the calls
observed hanging were queued by NCBI, whose own estimate of the time to
completion (`RTOE`) was ~2.5 hours at the time. It does mean that a
refused request is retried and then reported, rather than silently
producing an empty results file.

Also adds `pythonpath` to the pytest configuration, so that tests can
import the modules in the `ProteinCartography` package the same way the
snakemake rules do (as top-level modules).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
`envs/cartography_test.yml` left `mamba` unpinned, so it now resolves to
mamba 2.x. Mamba 2 removed the `mamba env create` CLI that snakemake
7.25.3 invokes, so `--conda-frontend mamba` fails with a
`CreateCondaEnvironmentException` and no output from the frontend.

Pinned to 1.4.2, matching `cartography_tidy.yml`, which is the version
the pipeline has been run with successfully.

CI does not hit this, because `.github/workflows/test.yml` forces the
conda frontend (with a comment noting that the mamba frontend "results in
errors during env creation" — this is that error, and this is its cause).
Anyone running the mamba frontend locally does hit it.

This is the same class of problem as the unpinned transitive dependencies
fixed earlier in this branch: a dependency left unpinned in 2023 that
resolves to an incompatible major version today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
Two rules built an output path from an f-string that mixed an
interpolated value with an escaped snakemake wildcard:

    f"{ANALYSIS_NAME}_aggregated_features_{{plotting_mode}}.html"

The doubled braces are there so that the f-string renders the literal
`{plotting_mode}` that snakemake needs as a wildcard. That is easy to
misread, and a formatter can silently change what it means: running
`make format` under Python 3.12 or later rewrites the doubled braces to
single ones, which turns the wildcard into an interpolation of a name
that does not exist. (Python 3.12 changed how f-strings are tokenized,
see PEP 701; the versions of snakefmt and black pinned here predate it.)

Concatenating a plain string avoids the escaping entirely, produces a
byte-identical path, and cannot be rewritten this way. Verified that the
DAG still resolves the same targets, with the wildcards expanding to
`..._aggregated_features_pca_umap.html` and
`..._P60709_distribution_analysis.svg` as before, and that
`snakefmt --check` now passes under both Python 3.9 (which CI uses) and
Python 3.12+, where it previously wanted to rewrite the file.

Note that the doubled braces elsewhere in the Snakefile are not affected:
they appear in a plain (non-f) string and in a shell block, neither of
which a formatter rewrites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
…che restore-keys

Three environment files declared no python at all, so each resolved to whatever
the newest python conda-forge had builds for every other pin in the file. They
had already drifted apart from each other and from the six environments that do
pin it, which are all on 3.9.16: solved as they were, `analysis.yml` gave python
3.11 and `plotting.yml` gave 3.10.20. With this change all three solve at 3.9.16,
and the `analysis` environment imports matplotlib.pyplot, pkg_resources and umap
-- the three imports that were failing before the pins on this branch.

This is the same class of drift as the rest of this branch, and it is what put
`analysis.yml` on a python new enough to resolve a setuptools that removed
`pkg_resources`, which is the failure the `setuptools<81` pin here addresses.

Also remove the `restore-keys` from the conda env cache rather than correcting
their prefix, which is what this branch did previously. A `restore-keys` match
still leaves `cache-hit` false, so the env creation step runs regardless, and
that step begins by deleting `.snakemake/conda`. Making the prefix match would
therefore download a stale multi-gigabyte cache only to discard it -- strictly
slower than the broken prefix that never matched anything.

Finally, correct the reason given for the matplotlib pin. Recent matplotlib and
`numpy=1.23.5` are not incompatible to the solver, which installs them together
without complaint; matplotlib is built against numpy 2 and fails on import. The
distinction matters because it means the breakage cannot be caught when the
environment is built, only when it is used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
…timeout

`blastp -remote` polls NCBI's queue indefinitely and gives the caller no way to
bound how long it waits. When NCBI's public queue is busy it blocks for hours
while consuming no CPU and producing no output: a run observed while
investigating this used 0.31s of CPU over 51 minutes and wrote a 0-byte results
file, and NCBI's own time estimate for the same query was 9168s (~2.5 hours).
This reproduces identically on macOS arm64 and x86-64 Linux, and is caused by
queue load rather than by any of the arguments the pipeline passes.

Submit the search to NCBI's URL API directly instead, so that the pipeline can
give up after a configurable timeout and say why it gave up:

  - `blast_timeout_seconds` (default 1800) bounds a single search, and is
    plumbed through the Snakefile rule and `run_blast.py` like the other blast
    parameters. On timeout the error reports both how long it waited and what
    NCBI's estimate was.
  - `blast_email` (overridable with the PROTEINCARTOGRAPHY_BLAST_EMAIL env
    variable) and a `tool=proteincartography` parameter identify the client, as
    NCBI asks automated clients to do.
  - Polling follows NCBI's etiquette: the first poll happens after the returned
    RTOE, and subsequent polls are at least 60 seconds apart. Both waits are
    shortened when less of the timeout remains, so the timeout is honored
    exactly rather than being overshot by a long RTOE.
  - The `run_blast` rule declares an `ncbi_remote` resource, so that a run with
    many input proteins can be stopped from submitting concurrent searches with
    `--resources ncbi_remote=1`. It has no effect unless that budget is given.

The database stays `nr`: the BLAST arm contributes sequence-space hits alongside
the Foldseek structure search, and the alternatives that would avoid NCBI's
queue either lack `nr`, cap the hit list below the 3000 the config asks for, or
return UniProt rather than RefSeq accessions.

The results keep the exact `-outfmt 6` column layout that
`extract_blast_hits.py` reads back. NCBI's URL API does not accept a custom
field list, so `sacc` and `saccver` are derived from the subject sequence id and
the three fields the API cannot supply (`sgi`, `staxids`, `scomnames`) are
written as 'N/A'. Only `sacc` is read downstream, so the pipeline's results are
unaffected.

`blast_call_failed` and the word-size backoff in `run_blast.py` are unchanged:
the search still reports itself as a `CompletedProcess` whose stderr is prefixed
with "Error:" when it failed. The `PROTEINCARTOGRAPHY_BLAST_STUB_RESULTS_FILEPATH`
short-circuit is also unchanged, so `make smoke-test` still runs the pipeline
end to end without touching the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
Two fixes to the switch from `blastp -remote` to NCBI's URL API.

NCBI does not deliver `FORMAT_TYPE=Tabular`. Fetching a completed search with it
returns a 62-byte body carrying only the status block and no hits, with or
without `FORMAT_OBJECT=Alignment`. Probing one completed search (RID
7TJ2P2DR014) gave:

    Tabular : 62 bytes (nothing but the status block)
    Text    : 157,935 bytes
    XML2_S  : 511,934 bytes
    JSON2_S : 422,458 bytes

Retrieve `JSON2_S` and convert it to the pipeline's column layout instead. The
JSON carries more than the tabular format would have, so 'staxids' is now
populated from each hit's taxid rather than written as 'N/A'.

Converting rather than reading a preformatted table means the remaining fields
have to be derived, and the derivations were checked against real output:

  - 'qseqid' comes from the query title. NCBI replaces the query's id with an
    internal one ('Query_5706473') and keeps the FASTA defline in `query_title`,
    so the id `blastp` would report is the title's first token.
  - Only the first description of each hit is emitted. `nr` merges identical
    sequences, so one hit can carry many accessions -- 1053 of them in one hit
    of the search above -- and `-outfmt 6` reports only the representative in
    'sacc' (reporting all of them is what 'sallacc' is for). Every row of the
    committed `blastp -remote` results file corresponds to one hit in this way,
    and always to the first description.
  - 'gapopen' is counted as runs of '-' in the aligned sequences. NCBI's `gaps`
    is the number of gapped positions, which is a different number whenever a
    gap is longer than one residue.
  - e-values and bit scores are formatted with NCBI's own thresholds, so that
    the file looks like one `blastp` wrote (0.0 rather than 0, 788 rather than
    787.719).
  - 'sgi' is a literal 0, as `blastp` writes for retired GI numbers, and
    'scomnames' stays 'N/A': the JSON has `sciname`, but that is a scientific
    name and this column is defined as the common name, so filling it in would
    mislabel the data.

Converting the real response for RID 7TJ2P2DR014 reproduces the row from the
committed `blastp -remote` results file exactly in 16 of 17 fields for the hit
they share (XP_052610122). The 17th is the bit score, which differs by less than
one because the two searches ran against different snapshots of `nr`. That
response is committed, trimmed from 50 hits to 4, as a test fixture so the
parser is exercised against genuine NCBI output rather than only hand-written
mocks.

The timeout is now a budget for the whole rule rather than for one attempt. It
is established once, before the retry loop, and each attempt is given only what
is left of it, so the worst case is the configured value rather than
`blast_num_attempts` times it. Exhausting the budget stops further attempts and
reports how long was spent in total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
The database was hard-coded to `nr`. Runs that are bottlenecked on NCBI's queue
have a reason to want a different one, but which database is searched decides
which homologs are found, so it belongs in `config.yml` alongside the other
parameters that determine a map's contents rather than being changed in code.

Defaults to `nr`, which is what the pipeline has always searched, so this does
not change any existing run.

`nr_cluster_seq` (ClusteredNR) is documented as the alternative worth trying,
rather than one of the RefSeq-only databases: it is built by clustering `nr`,
so it does not drop the GenBank CDS translations, PDB, PIR and PRF entries that
`refseq_protein` excludes outright. Measured against NCBI on 2026-08-13, `nr`
and `refseq_protein` received identical time estimates and `refseq_protein`
finished no faster, so the smaller RefSeq databases do not appear to buy the
speed they are usually chosen for.

Also correct the request id expiry reported when NCBI returns 'UNKNOWN', from
24 hours to the 36 hours NCBI currently documents, and note in `run_blast` why
the id is printed as soon as it is known.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
`wait_for_search` checked the deadline again between waiting and polling, so a
search whose estimate was larger than the whole timeout was never polled at all:
the first wait consumed the budget, the check fired, and the search was reported
as having timed out without a single request being sent to NCBI.

This was the common case rather than an edge case. NCBI's estimate tracks how
busy its shared queue is rather than how long a particular search will take, and
is routinely several times the default timeout: the estimate of 9168s used in
the tests was measured against the real service, versus a default
`blast_timeout_seconds` of 1800. A search that NCBI had already finished would
wait out the whole budget and then fail.

Poll after every wait instead. The timeout still bounds the waiting, and a
search that turns out to be ready now succeeds.

The existing test passed either way, because it asserts on how long was waited
rather than on whether NCBI was asked. Add one that fails without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
Any `requests` exception raised while polling propagated out of `wait_for_search`
and ended the search. A single refused connection partway through a long wait
therefore discarded the request id and everything already spent waiting, and the
retry in `run_blast.py` submitted a new search to the back of NCBI's queue.

The search is queued on NCBI's side, so a failure to ask about it says nothing
about the search itself. Retry the poll instead, bounded both by
`MAX_CONSECUTIVE_POLL_FAILURES` and by the existing timeout.

Also correct the docstring, which claimed the timeout was "honored exactly".
Only the waiting is bounded: a request already in flight when the timeout
expires runs to completion, so a call can overrun by up to one
`HTTP_TIMEOUT_SECONDS`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
…ts file

Two responses were accepted that should not have been.

A report with a 'BlastOutput2' key holding no search object yielded nothing, so
an empty results file was written and the search reported success. The pipeline
then failed in `extract_blast_hits.py` with "no hits were returned", which is
the misleading failure this module exists to remove, and which is indistinguishable
there from a query that genuinely has no hits.

A body that parsed as JSON but was not an object (a top-level array, say) raised
an `AttributeError` from the parsing code. That is not one of the exceptions
`run_blast` catches, so it escaped as a traceback and was not retried.

Both are now reported as failed searches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
@mrubash1

Copy link
Copy Markdown
Contributor Author

Closing in favour of #107.

This PR replaced the BLAST transport wholesale. #103 has since merged its own www/QBlast implementation to main, so the choice is no longer between two proposals — it would now mean deleting working code. Keeping that transport and porting this PR's improvements onto it is the better trade.

#107 carries the substance across: the bounded total timeout, the budget spanning attempts, the consecutive-poll-failure cap, response validation, config parameters instead of environment variables, and a testable heartbeat. It also fixes a defect found while reading the merged implementation — _www_get treated any unrecognised response as READY, so a transient NCBI error page ended the search and was reported as a success with no hits.

What is deliberately not carried across is the JSON2_S parsing and the ~1200 lines of tests built around it. Those are validated row-for-row against real blastp CLI output and are stronger than the hand-rolled XML path, but swapping the parser is the wholesale replacement being avoided here. Worth revisiting only if the XML path is found to produce a wrong row.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant