Repository navigation
Bound limit and offset on search and typeahead - #25
Conversation
matthewberry
left a comment
There was a problem hiding this comment.
The ge=0 change works: negative limit/offset now get a 422 before any SQL runs. It leaves three gaps that I reproduced, two of them at limit=0, the value this PR keeps allowed. Details are inline.
Reproduced (PR head run against a fake DB with TestClient; finding 3 also checked against a throwaway postgres:16 container):
/search?limit=0returns anextlink to the same page, so a client followingnextnever stops./typeahead?limit=0is quietly treated aslimit=20.- An oversized
offset/limitstill ends in a 500 with the raw Postgres error (bigint out of range), the same failure this PR fixes for negative values.
Found by reading the code, not run:
- The PR description says
/ec_lookupalways uses 10.get_ec_lookupsetsparams.limit = 20(search.py:654), soget_ec_suggestionsusesparams.limit or 10= 20. The reason for leaving/ec_lookupalone still holds; only the number is wrong.
Not blocking:
- Every handler's
except Exceptionstill returnsstr(e)to the client (search.py:316). That's what actually leaks the error text; this PR removes two ways to trigger it. LIMIT/OFFSETare pasted into the SQL with f-strings (queries.py:167,:170,:364–:418). Passing them as bound parameters would enforce the type in one place. Today every caller goes through theQuery(...)checks, so this is about robustness, not a live bug.
Suggested changes: ge=1 on limit, le= bounds on limit and offset, and a test that asserts 422 and covers limit=0. Before switching to ge=1, check whether the frontend ever sends limit=0, since that would start getting a 422.
| ), | ||
| limit: Optional[int] = Query( | ||
| None, description="Maximum number of records to return" | ||
| None, ge=0, description="Maximum number of records to return" |
There was a problem hiding this comment.
limit=0 still passes, and on /search it gives a pagination link that never ends. With 5 matching rows:
GET /api/v1/search?accession=P51451&limit=0&offset=0
→ 200, next = …?accession=P51451&format=json&offset=0&limit=0 (the same page)
GET /api/v1/search?accession=P51451&limit=0&offset=3
→ 200, next = …offset=3&limit=0, previous = …offset=3&limit=0
current_offset + current_limit < total_count stays true and next_offset = current_offset + 0, so a client that follows next until it is null loops forever. The description says limit=0's behavior doesn't change, but that behavior is the bug. Suggest ge=1.
There was a problem hiding this comment.
| None, ge=0, description="Maximum number of records to return" | ||
| ), | ||
| offset: Optional[int] = Query(0, description="Number of records to skip"), | ||
| offset: Optional[int] = Query(0, ge=0, description="Number of records to skip"), |
There was a problem hiding this comment.
There's no upper bound, so a value that's too large still ends in the 500 this PR is fixing. offset=99999999999999999999 goes into the SQL as written (… LIMIT 5000 OFFSET 99999999999999999999), and Postgres 16 rejects it when it runs:
SELECT 1 LIMIT 5000 OFFSET 99999999999999999999; → ERROR: bigint out of range
SELECT 1 LIMIT 99999999999999999999; → ERROR: bigint out of range
The except Exception then returns that as a 500 with the raw message. Adding le= to both limit and offset closes it. On limit, it would also stop a request like limit=100000000 from skipping the 5000-row default and building the whole response in memory. I didn't test that last part.
There was a problem hiding this comment.
Reproduced: oversized values reach the SQL unchanged, and Postgres 16 rejects anything above 9223372036854775807 with "bigint out of range" (that value itself is accepted). Fixed in 58ec7bf: offset is capped at 2^63 - 1 on both endpoints, and limit at 100000 on /search and 100 on /typeahead. I didn't test the memory point either; the 100000 cap limits it but doesn't measure it.
| ), | ||
| limit: Optional[int] = Query( | ||
| 20, description="Maximum number of records to return" | ||
| 20, ge=0, description="Maximum number of records to return" |
There was a problem hiding this comment.
On typeahead, limit=0 passes this check, but the handler does limit = params.limit or 20 (and queries.py:297 repeats it), so 0 becomes 20:
GET /api/v1/typeahead?field_name=organism&search=homo&limit=0
→ SQL: … ORDER BY 1 ASC LIMIT 20 OFFSET 0
→ response: "limit": 20
/search treats the same limit=0 as zero rows. ge=1 here makes the two endpoints consistent. The same oversized-value point as on /search applies to this limit and offset.
There was a problem hiding this comment.
Reproduced: limit=0 produced LIMIT 20 in the SQL and "limit": 20 in the response. Fixed in 58ec7bf with ge=1, plus a cap of le=100 on typeahead limit. The frontend sends 20 or no limit, so it isn't affected.
| def test_negative_pagination_is_rejected(db, url): | ||
| response = TestClient(app).get(url) | ||
|
|
||
| assert 400 <= response.status_code < 500, response.text |
There was a problem hiding this comment.
The PR promises a 422 that names the bad parameter, but this accepts any 4xx. A 400 from somewhere else, or a 404 from a mistyped route, would still pass. Suggest assert response.status_code == 422 plus a check that detail names the parameter. A limit=0 case would also have caught the two limit=0 issues above.
There was a problem hiding this comment.
Agreed. When I wrote it I didn't know whether the check would land in Query(...) (422) or in the model (400 via parse_query_params). Now that it's in Query(...), 58ec7bf asserts == 422 and that detail has exactly one entry, at ["query", <param>]. It also adds limit=0, over-cap, and over-bigint cases, plus inclusive-boundary cases that must reach the SQL. Without the router change, the 6 new rejection cases fail.
|
|
||
|
|
||
| @pytest.fixture | ||
| def db(): |
There was a problem hiding this comment.
Nit: this repeats the dependency_overrides[get_db] setup and teardown from the client fixture in tests/test_search_csv.py. One shared fixture in a tests/conftest.py would serve both files.
There was a problem hiding this comment.
Done in 58ec7bf: db and client fixtures in tests/conftest.py, used by both test files. The CSV tests now get an AsyncMock db instead of None, and they still patch the query functions, so their behavior is the same.
|
Replying to the review summary (inline threads answered separately):
|
Replace the configurable CSV_MAX_ROWS with MAX_SEARCH_LIMIT, the same bound #25 put on limit. An explicit limit above it already gets a 422, so drop the CSV-only 400 for that case and its test. Clamp the cap check's OFFSET to MAX_BIGINT: with offset allowed up to the bigint maximum, offset + cap overflowed and Postgres returned a 500.
Problem
/api/v1/searchand/api/v1/typeaheadaccept any integer forlimitandoffsetand put it straight into the SQL text. Postgres rejects a negative value, or one above the bigint maximum (9223372036854775807), when the query runs. The handler'sexcept Exceptionturns that into a 500, and the raw Postgres error goes back to the client.I reproduced the negative-value cases against a throwaway Postgres 16.15 container with a minimal schema, running the real app with nothing mocked:
/search?accession=P51451&limit=-1/search?accession=P51451&limit=1&offset=-5/typeahead?field_name=organism&search=homo&limit=-1/typeahead?field_name=organism&search=homo&offset=-5The same requests without a negative value returned 200. For oversized values, I confirmed that they reach the SQL unchanged, and that Postgres 16 rejects
LIMIT/OFFSET 99999999999999999999with "bigint out of range" (while it accepts 9223372036854775807).limit=0was also wrong, though it didn't cause a 500:/search, it returned anextlink pointing back to the same page, so a client followingnextnever stops./typeahead,params.limit or 20silently turned it into 20.Fix
New bounds on the
Query(...)parameters ofparse_query_paramsandparse_typeahead_params:/search/typeaheadlimitoffsetValues outside these ranges now get a 422 that names the parameter, and no SQL runs.
limit=0and the caps reject requests that worked before. I checked the latestCLEANDB-frontendmain, and none of its requests are affected:/searchalways gets 10, 20, or 50 from the table's page-size options./typeaheadgets 20 or nolimitat all, so the backend default of 20 applies.Other API clients sending
limit=0, more than 100000 on/search, or more than 100 on/typeaheadwill now get a 422./ec_lookupdoesn't need the fix:parse_ec_lookup_paramsdoesn't readlimitfrom the query string, andget_ec_lookupalways sets it to 20.Tests
tests/test_pagination_bounds.pysends requests to the app with a fake database. It checks:detailnames that parameter, and the database is never queried. Covered: negative values,limit=0, one past eachlimitcap, and anoffsetone past the bigint maximum.Without the router change, the
limit=0, over-cap, and over-bigint cases fail.The fake-database
clientfixture now lives intests/conftest.pyand is shared withtests/test_search_csv.py.pytest: 16 passed.ruff check app testsreports the same 5 errors asmain, all in lines this PR doesn't touch.