Skip to content

fix(http): encode list query params as key[]=item so the server keeps every value - #44

Merged
rob-archastro merged 2 commits into
mainfrom
fix/list-query-params
Sep 15, 2026
Merged

rob-archastro merged 2 commits into
mainfrom
fix/list-query-params

Conversation

@rob-archastro

@rob-archastro rob-archastro commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review on ArchCode

Problem and author intent

A LongMemEval benchmark case on the archastro_docstore_sdk arm 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.

provider: await_indexing(pending = {cso_a, cso_b, ... 50 sources})
  -> knowledge_documents.list(source=[all 50], page_size=100)
     -> HttpClient.request(query={"source": [...50], "page_size": 100})
        -> httpx params=dict-with-list
           -> wire: source=cso_a&source=cso_b&...&page_size=100
              -> Plug query parser: repeated bare key, last value wins
                 -> conn.query_params == %{"source" => "cso_z", ...}
                    -> server filters on ONE source
  <- response lists documents for one source
provider: pending.discard(that one)   # 49 still pending
  sleep 1s, repeat

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:

$ python -c "import httpx; print(httpx.QueryParams({'source':['cso_a','cso_b','cso_c'],'page_size':100}))"
source=cso_a&source=cso_b&source=cso_c&page_size=100

What changed

_encode_query is 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:

{"source": ["cso_a","cso_b","cso_c"], "page_size": 100}

before  source=cso_a&source=cso_b&source=cso_c&page_size=100      -> server sees one source
after   source%5B%5D=cso_a&source%5B%5D=cso_b&source%5B%5D=cso_c&page_size=100   -> server sees three

Mapping each change to the mechanism it fixes:

  • The [] 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 (appendQueryString in src/ts/developer-platform-sdk/src/client/http-client.ts appends `${key}[]` per item). The Python SDK was the outlier.
  • Four call sites, not the two named in the issue. The async and sync request paths were the reported ones; the async and sync SSE stream paths 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:

  • Scalar encoding. {"q": "notes", "page_size": 25} still produces q=notes&page_size=25.
  • None-dropping. A None value 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.
  • Empty lists still contribute no query string.
  • Auth, refresh, path transformation, and every response path.

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 and None behave exactly as before.

They assert the encoded wire bytes through httpx.MockTransport, not the dict handed to httpx. This matters: the existing suite already asserted params={"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 passed
  • uv run pytest tests/contract tests/harness — 2622 passed
  • uv run ruff check — clean; uv run ruff format --check — 157 files already formatted

These are the same gates release.yml runs before bumping, so the release path is pre-verified.

Server-side confirmation that the new shape is the right one: Api.V1.Context.Documents.List in firstlanding declares param(:source, {:array, :string}), and ArchAstro.Schema.Validation.cast_value({:array, item_type}, value) when is_list(value) handles the list Plug produces from source[] — 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 + Test legs are red, and not on this change. They fail at the Audit JS tooling dependencies step 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 no package.json / package-lock.json.

Filed as #45, with the investigation: bumping fast-uri and qs clears three of nine, and the override that clears the rest breaks Prism and makes all 2616 contract tests uncollectable. That gate also runs inside release.yml, so #45 has to land before this can be released.

Follow-ups

  • This PR must be released before ArchAstro/firstlanding#13724 can bump its two archastro-sdk pins; the pin bump and its real-HTTP regression test are a second PR in that repo, sequenced after the release.
  • ArchAstro/firstlanding#13731 — this SDK exposes no timeout override at any level (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 writes array params as bare repeated keys, which Plug collapses to the last value archastro-elixir#23 — the Elixir SDK's Query.encode/1 has 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

… 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
@rob-archastro

Copy link
Copy Markdown
Contributor Author

Orchestrator review (read-only, verified on 839923f, rebased on current main which carries the #46 audit gate; all three Python legs green): GO.

Verified: all four query call sites (async/sync request, async/sync SSE stream) route through _encode_query; no other platform query builder exists in src/archastro (the phx_channel harness and websocket socket params are not platform REST calls). The bracket suffix percent-encodes to source%5B%5D, which Plug decodes before parsing, so the server receives a list and {:array, :string} casts it; this is byte-for-byte what the TypeScript SDK sends. The tests assert wire bytes through MockTransport, which is the only level that could have caught this, and the red-first claim matches the reproduction I ran independently.

Non-blocking:

  • None inside a list becomes the literal string "None" via str(item); the old path let httpx emit an empty value. Generated resources only pass list[str], so nothing hits it today, but filtering None items would keep the "unset means omitted" rule consistent inside lists.
  • The description says empty lists still contribute no query string; that is true ({"source[]": []} emits nothing) but untested. One assertion would make it a tested claim.

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.

@rob-archastro
rob-archastro merged commit 5887c64 into main Sep 15, 2026
6 checks passed
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