improve error handling for overly long search queries #2

merged
opened by zzstoatzz.io targeting main from fix-query-length-error-handling

when users submit search queries exceeding turbopuffer's 1024 character limit for BM25 text search, the application now returns a 400 Bad Request with a helpful error message instead of a generic 500 Internal Server Error.

changes:

  • add TurbopufferError enum to categorize different error types
  • parse turbopuffer API error responses to detect query length violations
  • return 400 status with user-friendly message for query length errors
  • maintain 500 status for genuine server errors

this fix ensures users understand the limitation and can adjust their queries accordingly, without falsely suggesting a server-side problem.

🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com

+1
Cargo.lock
···
"reqwest",
"serde",
"serde_json",
"tokio",
"tracing",
]
···
"reqwest",
"serde",
"serde_json",
+
"thiserror 1.0.69",
"tokio",
"tracing",
]
+1
Cargo.toml
···
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "multipart"] }
anyhow = "1.0"
dotenv = "0.15"
base64 = "0.22"
actix-governor = "0.10.0"
···
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "multipart"] }
anyhow = "1.0"
+
thiserror = "1.0"
dotenv = "0.15"
base64 = "0.22"
actix-governor = "0.10.0"
+16 -5
src/search.rs
···
use crate::config::Config;
use crate::embedding::EmbeddingClient;
-
use crate::turbopuffer::{QueryRequest, TurbopufferClient};
use actix_web::{web, HttpRequest, HttpResponse, Result as ActixResult};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
···
query = &query_text,
top_k = search_top_k as i64
);
-
actix_web::error::ErrorInternalServerError(format!(
-
"failed to query turbopuffer (BM25): {}",
-
e
-
))
})?
};
···
use crate::config::Config;
use crate::embedding::EmbeddingClient;
+
use crate::turbopuffer::{QueryRequest, TurbopufferClient, TurbopufferError};
use actix_web::{web, HttpRequest, HttpResponse, Result as ActixResult};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
···
query = &query_text,
top_k = search_top_k as i64
);
+
+
// return appropriate HTTP status based on error type
+
match e {
+
TurbopufferError::QueryTooLong { .. } => {
+
actix_web::error::ErrorBadRequest(
+
"search query is too long (max 1024 characters for text search). try a shorter query."
+
)
+
}
+
_ => {
+
actix_web::error::ErrorInternalServerError(format!(
+
"failed to query turbopuffer (BM25): {}",
+
e
+
))
+
}
+
}
})?
};
+43 -7
src/turbopuffer.rs
···
use anyhow::{Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct QueryRequest {
···
.context(format!("failed to parse query response: {}", body))
}
-
pub async fn bm25_query(&self, query_text: &str, top_k: usize) -> Result<QueryResponse> {
let url = format!(
"https://api.turbopuffer.com/v1/vectors/{}/query",
self.namespace
···
"include_attributes": ["url", "name", "filename"],
});
-
log::debug!("turbopuffer BM25 query request: {}", serde_json::to_string_pretty(&request)?);
let response = self
.client
···
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&request)
.send()
-
.await
-
.context("failed to send BM25 query request")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
-
anyhow::bail!("turbopuffer BM25 query failed with status {}: {}", status, body);
}
-
let body = response.text().await.context("failed to read response body")?;
log::debug!("turbopuffer BM25 response: {}", body);
let parsed: QueryResponse = serde_json::from_str(&body)
-
.context(format!("failed to parse BM25 query response: {}", body))?;
// DEBUG: log first result to see what BM25 returns
if let Some(first) = parsed.first() {
···
use anyhow::{Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
+
use thiserror::Error;
+
+
#[derive(Debug, Error)]
+
pub enum TurbopufferError {
+
#[error("query too long: {message}")]
+
QueryTooLong { message: String },
+
#[error("turbopuffer API error: {0}")]
+
ApiError(String),
+
#[error("request failed: {0}")]
+
RequestFailed(#[from] reqwest::Error),
+
#[error("{0}")]
+
Other(#[from] anyhow::Error),
+
}
+
+
#[derive(Debug, Deserialize)]
+
struct TurbopufferErrorResponse {
+
error: String,
+
#[allow(dead_code)]
+
status: String,
+
}
#[derive(Debug, Serialize)]
pub struct QueryRequest {
···
.context(format!("failed to parse query response: {}", body))
}
+
pub async fn bm25_query(&self, query_text: &str, top_k: usize) -> Result<QueryResponse, TurbopufferError> {
let url = format!(
"https://api.turbopuffer.com/v1/vectors/{}/query",
self.namespace
···
"include_attributes": ["url", "name", "filename"],
});
+
if let Ok(pretty) = serde_json::to_string_pretty(&request) {
+
log::debug!("turbopuffer BM25 query request: {}", pretty);
+
}
let response = self
.client
···
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&request)
.send()
+
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
+
+
// try to parse turbopuffer error response
+
if let Ok(error_resp) = serde_json::from_str::<TurbopufferErrorResponse>(&body) {
+
// check if it's a query length error
+
if error_resp.error.contains("too long") && error_resp.error.contains("max 1024") {
+
return Err(TurbopufferError::QueryTooLong {
+
message: error_resp.error,
+
});
+
}
+
}
+
+
return Err(TurbopufferError::ApiError(format!(
+
"turbopuffer BM25 query failed with status {}: {}",
+
status, body
+
)));
}
+
let body = response.text().await
+
.map_err(|e| TurbopufferError::Other(anyhow::anyhow!("failed to read response body: {}", e)))?;
log::debug!("turbopuffer BM25 response: {}", body);
let parsed: QueryResponse = serde_json::from_str(&body)
+
.map_err(|e| TurbopufferError::Other(anyhow::anyhow!("failed to parse BM25 query response: {}", e)))?;
// DEBUG: log first result to see what BM25 returns
if let Some(first) = parsed.first() {
+12 -1
static/index.html
···
});
if (!response.ok) {
-
throw new Error(`search failed: ${response.statusText}`);
}
const data = await response.json();
···
});
if (!response.ok) {
+
// try to extract error message from response body
+
let errorMessage = response.statusText;
+
try {
+
const errorText = await response.text();
+
// actix-web returns plain text error messages, not JSON
+
if (errorText) {
+
errorMessage = errorText;
+
}
+
} catch (e) {
+
// if reading body fails, use the status text
+
}
+
throw new Error(errorMessage);
}
const data = await response.json();