Skip to content

BioStudies serves 20,000 hits, then returns HTTP 500

Archive: EBI BioStudies (ArrayExpress collection) Affected route: study->ae_experiment:biostudies_search, and any enumeration of a collection Status: worked around; not yet reported upstream First observed: 2026-09-15

Symptom

A search that reports more than 20,000 totalHits can be paged only as far as the 20,000th. The next page is answered with HTTP 500, with no body explaining the limit — not 400 Bad Request, which is what a rejected parameter would earn, and not a short final page, which is what an exhausted result set looks like.

The status code is the problem. 500 is the canonical transient failure: this project's own is_retryable treats it as one, as does every HTTP retry library in common use. So the client retries a permanently impossible request, backs off, and retries again. The enumeration does not fail fast — it hangs.

It also fails late. The ArrayExpress collection holds 20,693 studies with an ENA link, so the failure lands at 96.6% of the way through, after twenty minutes of apparently healthy paging.

Reproducer

# 20,693 hits, and BioStudies says so
curl -s 'https://www.ebi.ac.uk/biostudies/api/v1/arrayexpress/search?pageSize=1&facet.link_type=ENA' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["totalHits"])'
# 20693

# the last page inside the window
curl -s -o /dev/null -w '%{http_code}\n' \
  'https://www.ebi.ac.uk/biostudies/api/v1/arrayexpress/search?pageSize=100&page=200&facet.link_type=ENA'
# 200

# the first page past it
curl -s -o /dev/null -w '%{http_code}\n' \
  'https://www.ebi.ac.uk/biostudies/api/v1/arrayexpress/search?pageSize=100&page=201&facet.link_type=ENA'
# 500

The cap is on the offset, not the page number: pageSize=500&page=41 is the same offset of 20,000 and fails identically. nextCursor is present in every response and is always null, so the cursor that would sidestep the window is advertised but never issued.

Expected

Either a documented cap returned as 400 with a message naming it, or a working nextCursor. A deep-paging limit is an entirely reasonable thing for a search service to have; answering it with 500 is what makes it a defect.

Workaround

Partition the query on facet.released_year and page each year separately. Every study has exactly one release year, so the partitions are disjoint and exhaustive. Implemented as fetch_series.providers.biostudies.search_by_year.

Verified: the union of the per-year enumerations came to exactly 20,693 distinct accessions, matching the totalHits of the unpartitioned query. No year in the ArrayExpress collection comes close to the window — the largest is under 2,200.

search() now raises rather than paging into the error, and refuses on the first page, where totalHits already says the enumeration cannot complete. Discovering this at page 201 costs 200 wasted requests and the reason is not visible in the failure; discovering it at page 1 costs one and names the cap.

Affected accessions

Not accession-specific: any query whose result set exceeds the window.

collection=arrayexpress                       80,863 hits
collection=arrayexpress&facet.link_type=ENA   20,693 hits

Regression test

tests/test_request_limits.py::TestBioStudiesSearchWindow — asserts that a search reporting more than SEARCH_WINDOW hits refuses on the first page, and that year partitioning covers a collection exactly once.