Skip to content

fix: reject a /v1/search 200 that is not a search response - #91

Merged
lukekim merged 2 commits into
spiceai:trunkfrom
claudespice:fix/search-reject-malformed-response
Oct 9, 2026
Merged

lukekim merged 2 commits into
spiceai:trunkfrom
claudespice:fix/search-reject-malformed-response

Conversation

@claudespice

@claudespice claudespice commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What

Search now returns an error when a 200 response is missing a field the runtime always sends. Before, it returned an empty result.

Why

Search decoded the body straight into SearchResponse, so every absent field became Go's zero value. A 200 carrying {}, or {"error": "upstream timeout"} from a proxy answering for the runtime, came back as &{Results:[] DurationMs:0} with a nil error. A caller can't tell that apart from a search that matched nothing, and neither can its telemetry.

The runtime always serializes these fields. results and duration_ms are in crates/runtime/src/http/v1/search.rs, and each match's dataset, _score and matches are in crates/runtime-search/src/types.rs (spiceai trunk 2c0c7b0723). primary_key, data and metadata are omitted when empty, so they stay optional. The error names the missing field, for example not a search response: result 0 is missing "_score".

Score also falls back to score, the name older runtimes used. The runtime's own search test client accepts the same alias. Before this change a score field decoded as 0, which is silently wrong.

spice-rs has the same fix in spiceai/spice-rs#105. spicepy gets it in a parallel PR.

Verification

The new tests fail on trunk ff450ab. go test -run TestSearch -v ., with the tests applied and the fix not applied:

    search_test.go:372: expected an error, got &{Results:[] DurationMs:0}
    search_test.go:372: expected an error, got &{Results:[{Dataset: Score:0.9 Matches:map[] PrimaryKey:map[] Data:map[] Metadata:map[]}] DurationMs:3}
    search_test.go:372: expected an error, got &{Results:[{Dataset:d Score:0 Matches:map[] PrimaryKey:map[] Data:map[] Metadata:map[]}] DurationMs:3}
    --- FAIL: TestSearchRejectsResponseMissingRequiredFields/empty_object
    --- FAIL: TestSearchRejectsResponseMissingRequiredFields/error_envelope
    --- FAIL: TestSearchRejectsResponseMissingRequiredFields/null_results
    --- FAIL: TestSearchRejectsResponseMissingRequiredFields/no_duration
    --- FAIL: TestSearchRejectsResponseMissingRequiredFields/match_without_dataset
    --- FAIL: TestSearchRejectsResponseMissingRequiredFields/match_without_score
    --- FAIL: TestSearchRejectsResponseMissingRequiredFields/match_without_matches
    --- FAIL: TestSearchRejectsResponseMissingRequiredFields/null_match
--- FAIL: TestSearchAcceptsLegacyScoreName
    search_test.go:406: Score = 0, want 0.5

With the fix: ok github.com/spiceai/gospice/v9 for -run TestSearch.

Gate on this branch:

  • go build ./... ok
  • go vet ./... ok
  • golangci-lint run ./... printed 0 issues.
  • go test -count=1 ./... had 5 failures: TestADBCLocalParameterizedQuery, TestADBCLocalBasicQuery, TestAsyncQueryLocal, TestLocalRuntimeDatasetRefresh, TestLocalRuntime. All 5 need a local runtime and fail on trunk here too.
  • Integration tests against a live runtime: not run locally, no runtime available. CI installs one.

Review gate

Attests the review-fix commit 70f696e (fix-failing-prs), not the original change.

  • Adversarial review: codex — job review-muyo83j6-skde52 on the first draft: needs-attention, one high finding (a cased duplicate key such as RESULTS beside results made the struct decoder and the exact-key check read different arrays → index-out-of-range panic). Reproduced (panic: runtime error: index out of range [0] with length 0), fixed by rejecting case variants plus a length guard, regression tests added. Re-run review-muyo9v4x-m8cpld: approve, no findings
  • Security review: by hand on the 2-file fix (response decoding only) — no findings
  • Reproduction: go test -run TestSearch ./... before the fix: 4 uppercase-key subtests expected an error, got &{Results:[] DurationMs:3}; after: ok. golangci-lint run ./...: 0 issues

Search decoded the body straight into SearchResponse, so a 200 carrying
{} or {"error": "..."} came back as zero results in 0ms, the same as a
real empty search. The runtime always sends results, duration_ms, and each
match's dataset, _score and matches, so a body missing one is now an error
naming the field. Score also falls back to "score", the name older
runtimes used, which previously decoded as 0.

Signed-off-by: claudespice <270518434+claudespice@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Case-insensitive JSON matching still permits malformed responses without the exact required field names.

1 open finding
What changed in this PR

Adds strict validation for /v1/search responses and legacy score compatibility.

Changes:

  • Rejects responses missing required search fields.
  • Supports the legacy score field.
  • Adds regression tests for malformed responses.
File Description
search.go Validates response fields and handles legacy scores.
search_test.go Tests required fields, empty results, and legacy scores.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread search.go Outdated
encoding/json matches struct tags case-insensitively, so the presence check
accepted bodies like {"RESULTS":[],"DURATION_MS":3} that carry none of the
fields the runtime sends. Look keys up by exact name in a raw map instead.

Signed-off-by: claudespice <270518434+claudespice@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

The validation matches the runtime response contract and includes focused regression coverage for the identified failure modes.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

@grokspice

Copy link
Copy Markdown

Ready to merge, blocked only on a maintainer approval.

Read back just now:

  • mergeable/mergeStateStatus = MERGEABLE/CLEAN against the current base tip
  • statusCheckRollup = SUCCESS — 7/7 checks green on 70f696e1fbea53f765d97c1b1e83e1b5da9ad290
  • unresolved review threads: 0 (re-queried via GraphQL reviewThreads)
  • reviewDecision = APPROVED, but the only approval is from copilot-pull-request-reviewer. There is no standing approval from a member of spiceai/spice-core-maintainers, so the automated merge gate refuses to enqueue it.

Nothing here is fixable by a bot: it needs a review from a maintainer. This PR is one of a set of sibling fixes landing the same validation across the SDKs.

@lukekim
lukekim merged commit 134e7b5 into spiceai:trunk Oct 9, 2026
7 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.

4 participants