fix(http): encode list query params as key[]=item so the server keeps every value - #44
Conversation
… every value httpx writes a list-valued query parameter under a bare repeated key (source=a&source=b). The ArchAstro platform parses query strings with Plug, whose parser keeps only the last value for a repeated bare key, so a multi-value filter silently narrowed to one element server-side. The request succeeded and the filter was wrong. Route all four call sites — async request, sync request, and both SSE stream paths — through one _encode_query helper that suffixes list keys with [] per element, matching the TypeScript SDK's appendQueryString. Scalar handling and None-dropping are unchanged. The contract tests pin the encoded wire bytes through httpx.MockTransport rather than the dict handed to httpx, because the defect only becomes visible after httpx encodes. Refs ArchAstro/firstlanding#13724 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMtJ4fwgPjiG8Zcv6bEkBi
|
Orchestrator review (read-only, verified on Verified: all four query call sites (async/sync Non-blocking:
Release note worth one line: array filters change wire shape from bare repeated keys to bracketed keys. Downstream: once released, ArchAstro/firstlanding#13724 bumps both pins and adds the real-HTTP regression. |
Review on ArchCode
Problem and author intent
A LongMemEval benchmark case on the
archastro_docstore_sdkarm spent about 66 seconds per case waiting for documents the platform had already indexed within a second. The platform was not slow. The SDK was asking the wrong question, 50 times instead of once.The intent is to fix the query encoding at the SDK — the layer that owns it — so a multi-value filter reaches the server intact. Driven by ArchAstro/firstlanding#13724.
The failure as a system story
Actors: the benchmark provider's poll loop, this SDK's
HttpClient, httpx, and the platform's Plug-based API DSL.The violated invariant is that a list-valued query parameter round-trips as a list. httpx encodes a Python list under a bare repeated key; Plug's query parser keeps only the last value for a repeated bare key. Neither side is wrong on its own — the encoding simply is not the one the server parses. Nothing errors; the filter is just quietly narrowed to one element.
Impact: a case with N sources took roughly N x 1.3s instead of one round trip. Expected behaviour is one request that retires every pending source.
Reproduction on
main:What changed
_encode_queryis a single helper that drops unset parameters and suffixes list-valued keys with[], one pair per element. All four query call sites route through it.Before → after, same input:
Mapping each change to the mechanism it fixes:
[]suffix is what makes Plug's parser produce a list instead of last-wins. It is not cosmetic, and it matches what the TypeScript SDK has always sent (appendQueryStringinsrc/ts/developer-platform-sdk/src/client/http-client.tsappends`${key}[]`per item). The Python SDK was the outlier.requestpaths were the reported ones; the async and sync SSEstreampaths built params with the same collapsing expression and are fixed by the same helper. Leaving them would have left the identical defect behind a different method.Intentionally unchanged:
{"q": "notes", "page_size": 25}still producesq=notes&page_size=25.None-dropping. ANonevalue still means "not provided" and is omitted entirely; this SDK does not adopt the TypeScript client's explicit-null-as-empty-value behaviour here, which would be a separate decision.Caveat: this changes the wire shape for array params. Any server that expected bare repeated keys from this client would now see bracketed ones — the ArchAstro platform is the intended and, as far as this repo knows, only target.
Testing
Four contract tests in
tests/test_http_client.py, async and sync:test_async_list_query_params_encode_as_bracket_suffixed_repeats/test_sync_...— assert the exact encoded query bytes and that all three values survive under one key.test_async_scalar_query_params_are_unchanged_and_none_is_dropped/test_sync_...— pin that scalars andNonebehave exactly as before.They assert the encoded wire bytes through
httpx.MockTransport, not the dict handed to httpx. This matters: the existing suite already assertedparams={"limit": 10}at the dict boundary and passed throughout the bug's life, because the defect only appears after httpx encodes. A dict-level assertion could not have caught this and could not catch a regression.Red-first confirmed — the pre-fix encoding is
source=cso_a&source=cso_b&source=cso_c&page_size=100, which fails the new assertion.Results:
uv run pytest tests/test_http_client.py— 46 passeduv run pytest tests/contract tests/harness— 2622 passeduv run ruff check— clean;uv run ruff format --check— 157 files already formattedThese are the same gates
release.ymlruns before bumping, so the release path is pre-verified.Server-side confirmation that the new shape is the right one:
Api.V1.Context.Documents.Listinfirstlandingdeclaresparam(:source, {:array, :string}), andArchAstro.Schema.Validation.cast_value({:array, item_type}, value) when is_list(value)handles the list Plug produces fromsource[]— a collapsed single binary does not take that clause.Scope and risk
Backend SDK only. Risk: low. The change is confined to query-string construction; no auth, response, or streaming behaviour moves.
User impact
No behavioural change for scalar parameters, which is every query the generated resources send today apart from array filters. Callers passing array filters go from silently-wrong results to correct ones.
CI status
The three
Lint + Testlegs are red, and not on this change. They fail at theAudit JS tooling dependenciesstep before any Python test runs — nine npm advisories published since this repo's last green run on 2026-08-19, against JS dev tooling (Prism mock server,fast-uri,qs, a transitive@faker-js/faker). This diff touches two Python files and nopackage.json/package-lock.json.Filed as #45, with the investigation: bumping
fast-uriandqsclears three of nine, and the override that clears the rest breaks Prism and makes all 2616 contract tests uncollectable. That gate also runs insiderelease.yml, so #45 has to land before this can be released.Follow-ups
archastro-sdkpins; the pin bump and its real-HTTP regression test are a second PR in that repo, sequenced after the release.httpx.AsyncClient(timeout=30.0)is a hardcoded literal), so the benchmark harness's per-call deadline contract is inert for the SDK arm.Query.encode/1has the same defect, currently latent, and its bare-key shape is pinned by a test.🤖 Generated with Claude Code
https://claude.ai/code/session_01MMtJ4fwgPjiG8Zcv6bEkBi