Skip to content

Commit 514f07e

Browse files
Stop sending a null threshold, and show what the engine actually said
Live testing: mem0 store and list succeeded, recall answered 400. The cause is that `RecallOpts::min_score` is an `Option<f64>` and the search body interpolated it directly, so an unset minimum serialised as `"threshold": null`. The hosted platform types that field as a number in 0..=1 and rejects an explicit null. `search_body` now omits the field when no minimum was asked for, and clamps `top_k` into the documented 1..=1000 for the same reason -- a limit outside the range is a validation error, not a smaller result set. The 400 was harder to diagnose than it should have been, because the error carried a status and nothing else. Hosted engines explain themselves in the response body -- mem0 answers `{"detail": "..."}`, cognee likewise -- and `status_error` was discarding it, turning "this one field is invalid" into "something, somewhere, was wrong". It now includes the body, truncated to 300 characters: an error body is not a payload budget, and only error bodies reach this path. Both flavours share the builder, so the self-hosted arm stops sending a null threshold too -- its server tolerated it, which is why this went unnoticed there. cargo test -p tinymemory-remote --lib: 27 passed (3 new, pinning the omitted threshold, the sent one, and the clamp)
1 parent d146bf2 commit 514f07e

2 files changed

Lines changed: 91 additions & 12 deletions

File tree

‎adapters/remote/src/common.rs‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,24 @@ impl HttpClient {
196196
/// rejected credential specifically, because "HTTP 401" three layers deep
197197
/// in an anyhow chain reads as "the engine is down" and sends the operator
198198
/// to the wrong runbook.
199-
fn status_error(&self, path: &str, status: reqwest::StatusCode) -> anyhow::Error {
199+
fn status_error(&self, path: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
200200
let host = self.endpoint.host_str().unwrap_or("<endpoint>");
201+
// Hosted engines explain a rejection in the response body — mem0
202+
// answers `{"detail": "..."}`, cognee likewise — and discarding it
203+
// turned "this one field is invalid" into a bare status code that
204+
// said only that something, somewhere, was wrong. Truncated because
205+
// an error body is not a payload budget, and only ever an error
206+
// body: success responses never reach here.
207+
let detail = body.trim();
208+
let detail = if detail.is_empty() {
209+
String::new()
210+
} else {
211+
let mut shown: String = detail.chars().take(300).collect();
212+
if detail.chars().count() > 300 {
213+
shown.push('…');
214+
}
215+
format!(" — {shown}")
216+
};
201217
match status.as_u16() {
202218
401 | 403 => {
203219
let hint = match &self.auth {
@@ -209,10 +225,10 @@ impl HttpClient {
209225
};
210226
anyhow::anyhow!(
211227
"memory API {path} on {host}: the configured credential was rejected \
212-
(HTTP {status}) — {hint}"
228+
(HTTP {status}) — {hint}{detail}"
213229
)
214230
}
215-
_ => anyhow::anyhow!("memory API {path} on {host} returned HTTP {status}"),
231+
_ => anyhow::anyhow!("memory API {path} on {host} returned HTTP {status}{detail}"),
216232
}
217233
}
218234

@@ -232,7 +248,8 @@ impl HttpClient {
232248
.map_err(|error| self.transport_error(error))?;
233249
let status = response.status();
234250
if !status.is_success() {
235-
return Err(self.status_error(path, status));
251+
let body = response.text().await.unwrap_or_default();
252+
return Err(self.status_error(path, status, &body));
236253
}
237254
let body = read_capped(response, path).await?;
238255
serde_json::from_slice(&body)
@@ -248,7 +265,8 @@ impl HttpClient {
248265
.map_err(|error| self.transport_error(error))?;
249266
let status = response.status();
250267
if !status.is_success() {
251-
return Err(self.status_error(path, status));
268+
let body = response.text().await.unwrap_or_default();
269+
return Err(self.status_error(path, status, &body));
252270
}
253271
let body = read_capped(response, path).await?;
254272
String::from_utf8(body).context("memory API response was not valid UTF-8")
@@ -271,7 +289,8 @@ impl HttpClient {
271289
.map_err(|error| self.transport_error(error))?;
272290
let status = response.status();
273291
if !status.is_success() {
274-
return Err(self.status_error(path, status));
292+
let body = response.text().await.unwrap_or_default();
293+
return Err(self.status_error(path, status, &body));
275294
}
276295
Ok(status)
277296
}

‎adapters/remote/src/mem0.rs‎

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,26 @@ impl Mem0Dialect {
286286
}
287287
}
288288

289+
/// The search body both flavours send.
290+
///
291+
/// `threshold` is **omitted** rather than sent as null when the caller set
292+
/// no minimum score: the platform types it as a number in 0..=1 and
293+
/// rejects an explicit null with a 400, which is how a recall against
294+
/// mem0's hosted API failed while store and list succeeded. `top_k` is
295+
/// clamped to the documented 1..=1000 for the same reason — a limit
296+
/// outside it is a validation error, not a smaller result set.
297+
fn search_body(query: &str, limit: usize, filters: Value, min_score: Option<f64>) -> Value {
298+
let mut body = json!({
299+
"query": query,
300+
"filters": filters,
301+
"top_k": limit.clamp(1, 1000),
302+
});
303+
if let (Some(object), Some(threshold)) = (body.as_object_mut(), min_score) {
304+
object.insert("threshold".into(), json!(threshold));
305+
}
306+
body
307+
}
308+
289309
/// The path addressing one record by its remote id.
290310
///
291311
/// The platform serves the by-id operations under **v1** while add,
@@ -421,9 +441,12 @@ impl Dialect for Mem0Dialect {
421441
.json(
422442
Method::POST,
423443
"search",
424-
Some(&json!({
425-
"query": query, "filters": filters, "top_k": limit, "threshold": opts.min_score
426-
})),
444+
Some(&Self::search_body(
445+
query,
446+
limit,
447+
Value::Object(filters),
448+
opts.min_score,
449+
)),
427450
)
428451
.await?
429452
}
@@ -442,9 +465,7 @@ impl Dialect for Mem0Dialect {
442465
.json(
443466
Method::POST,
444467
"v3/memories/search/",
445-
Some(&json!({
446-
"query": query, "filters": filters, "top_k": limit, "threshold": opts.min_score
447-
})),
468+
Some(&Self::search_body(query, limit, filters, opts.min_score)),
448469
)
449470
.await?
450471
}
@@ -483,3 +504,42 @@ impl Dialect for Mem0Dialect {
483504
#[cfg(test)]
484505
#[path = "mem0_test.rs"]
485506
mod test;
507+
508+
#[cfg(test)]
509+
mod search_body_tests {
510+
use super::*;
511+
512+
/// A recall with no minimum score must omit `threshold`, not send null.
513+
/// The hosted platform types it as a number in 0..=1 and answers 400 to
514+
/// an explicit null — store and list succeeded while recall failed.
515+
#[test]
516+
fn an_unset_min_score_omits_the_threshold_field() {
517+
let body = Mem0Dialect::search_body("q", 10, json!({"user_id": "ns"}), None);
518+
assert!(
519+
body.get("threshold").is_none(),
520+
"threshold must be absent, not null: {body}"
521+
);
522+
assert_eq!(body["top_k"], 10);
523+
assert_eq!(body["query"], "q");
524+
}
525+
526+
#[test]
527+
fn a_set_min_score_is_sent() {
528+
let body = Mem0Dialect::search_body("q", 10, json!({"user_id": "ns"}), Some(0.25));
529+
assert_eq!(body["threshold"], 0.25);
530+
}
531+
532+
/// `top_k` outside the documented 1..=1000 is a validation error, so a
533+
/// caller's limit is clamped rather than forwarded into a 400.
534+
#[test]
535+
fn top_k_is_clamped_to_the_documented_range() {
536+
assert_eq!(
537+
Mem0Dialect::search_body("q", 0, json!({}), None)["top_k"],
538+
1
539+
);
540+
assert_eq!(
541+
Mem0Dialect::search_body("q", 5000, json!({}), None)["top_k"],
542+
1000
543+
);
544+
}
545+
}

0 commit comments

Comments
 (0)