improve error handling for overly long search queries

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](https://claude.com/claude-code)

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

+1
Cargo.lock
··· 686 686 "reqwest", 687 687 "serde", 688 688 "serde_json", 689 + "thiserror 1.0.69", 689 690 "tokio", 690 691 "tracing", 691 692 ]
+1
Cargo.toml
··· 12 12 tokio = { version = "1", features = ["full"] } 13 13 reqwest = { version = "0.12", features = ["json", "multipart"] } 14 14 anyhow = "1.0" 15 + thiserror = "1.0" 15 16 dotenv = "0.15" 16 17 base64 = "0.22" 17 18 actix-governor = "0.10.0"
+16 -5
src/search.rs
··· 42 42 43 43 use crate::config::Config; 44 44 use crate::embedding::EmbeddingClient; 45 - use crate::turbopuffer::{QueryRequest, TurbopufferClient}; 45 + use crate::turbopuffer::{QueryRequest, TurbopufferClient, TurbopufferError}; 46 46 use actix_web::{web, HttpRequest, HttpResponse, Result as ActixResult}; 47 47 use serde::{Deserialize, Serialize}; 48 48 use std::collections::hash_map::DefaultHasher; ··· 228 228 query = &query_text, 229 229 top_k = search_top_k as i64 230 230 ); 231 - actix_web::error::ErrorInternalServerError(format!( 232 - "failed to query turbopuffer (BM25): {}", 233 - e 234 - )) 231 + 232 + // return appropriate HTTP status based on error type 233 + match e { 234 + TurbopufferError::QueryTooLong { .. } => { 235 + actix_web::error::ErrorBadRequest( 236 + "search query is too long (max 1024 characters for text search). try a shorter query." 237 + ) 238 + } 239 + _ => { 240 + actix_web::error::ErrorInternalServerError(format!( 241 + "failed to query turbopuffer (BM25): {}", 242 + e 243 + )) 244 + } 245 + } 235 246 })? 236 247 }; 237 248
+43 -7
src/turbopuffer.rs
··· 1 1 use anyhow::{Context, Result}; 2 2 use reqwest::Client; 3 3 use serde::{Deserialize, Serialize}; 4 + use thiserror::Error; 5 + 6 + #[derive(Debug, Error)] 7 + pub enum TurbopufferError { 8 + #[error("query too long: {message}")] 9 + QueryTooLong { message: String }, 10 + #[error("turbopuffer API error: {0}")] 11 + ApiError(String), 12 + #[error("request failed: {0}")] 13 + RequestFailed(#[from] reqwest::Error), 14 + #[error("{0}")] 15 + Other(#[from] anyhow::Error), 16 + } 17 + 18 + #[derive(Debug, Deserialize)] 19 + struct TurbopufferErrorResponse { 20 + error: String, 21 + #[allow(dead_code)] 22 + status: String, 23 + } 4 24 5 25 #[derive(Debug, Serialize)] 6 26 pub struct QueryRequest { ··· 64 84 .context(format!("failed to parse query response: {}", body)) 65 85 } 66 86 67 - pub async fn bm25_query(&self, query_text: &str, top_k: usize) -> Result<QueryResponse> { 87 + pub async fn bm25_query(&self, query_text: &str, top_k: usize) -> Result<QueryResponse, TurbopufferError> { 68 88 let url = format!( 69 89 "https://api.turbopuffer.com/v1/vectors/{}/query", 70 90 self.namespace ··· 76 96 "include_attributes": ["url", "name", "filename"], 77 97 }); 78 98 79 - log::debug!("turbopuffer BM25 query request: {}", serde_json::to_string_pretty(&request)?); 99 + if let Ok(pretty) = serde_json::to_string_pretty(&request) { 100 + log::debug!("turbopuffer BM25 query request: {}", pretty); 101 + } 80 102 81 103 let response = self 82 104 .client ··· 84 106 .header("Authorization", format!("Bearer {}", self.api_key)) 85 107 .json(&request) 86 108 .send() 87 - .await 88 - .context("failed to send BM25 query request")?; 109 + .await?; 89 110 90 111 if !response.status().is_success() { 91 112 let status = response.status(); 92 113 let body = response.text().await.unwrap_or_default(); 93 - anyhow::bail!("turbopuffer BM25 query failed with status {}: {}", status, body); 114 + 115 + // try to parse turbopuffer error response 116 + if let Ok(error_resp) = serde_json::from_str::<TurbopufferErrorResponse>(&body) { 117 + // check if it's a query length error 118 + if error_resp.error.contains("too long") && error_resp.error.contains("max 1024") { 119 + return Err(TurbopufferError::QueryTooLong { 120 + message: error_resp.error, 121 + }); 122 + } 123 + } 124 + 125 + return Err(TurbopufferError::ApiError(format!( 126 + "turbopuffer BM25 query failed with status {}: {}", 127 + status, body 128 + ))); 94 129 } 95 130 96 - let body = response.text().await.context("failed to read response body")?; 131 + let body = response.text().await 132 + .map_err(|e| TurbopufferError::Other(anyhow::anyhow!("failed to read response body: {}", e)))?; 97 133 log::debug!("turbopuffer BM25 response: {}", body); 98 134 99 135 let parsed: QueryResponse = serde_json::from_str(&body) 100 - .context(format!("failed to parse BM25 query response: {}", body))?; 136 + .map_err(|e| TurbopufferError::Other(anyhow::anyhow!("failed to parse BM25 query response: {}", e)))?; 101 137 102 138 // DEBUG: log first result to see what BM25 returns 103 139 if let Some(first) = parsed.first() {