Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 61 additions & 5 deletions src/aggregator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ use crate::query::{Query, TimeRange};
use crate::results::{Infobox, ResultContainer, SearchResult, Timing, UnresponsiveEngine};
use crate::settings::Settings;

/// Largest per-request `timeout_limit`, in seconds, that the HTTP layer
/// accepts. The default search deadline is 3.0 seconds; 60 seconds matches
/// the largest timeout already present in the settings (the research and
/// LLM timeouts) and leaves ample headroom without letting one request
/// hold a search open indefinitely.
pub(crate) const MAX_TIMEOUT_LIMIT: f64 = 60.0;

/// Whether a caller-supplied `timeout_limit` (in seconds) may be honoured:
/// finite, positive and within the server cap.
pub(crate) fn is_valid_timeout_limit(value: f64) -> bool {
value.is_finite() && value > 0.0 && value <= MAX_TIMEOUT_LIMIT
}

/// Resolve the effective search timeout in seconds. A non-finite or
/// non-positive override — reachable through the library API, which
/// bypasses the HTTP validation — falls back to the configured limit.
fn resolve_timeout_limit(requested: Option<f64>, configured: f64) -> f64 {
requested
.filter(|value| value.is_finite() && *value > 0.0)
.unwrap_or(configured)
}

/// Everything the searcher needs to know about one search request.
#[derive(Debug, Clone)]
pub struct SearchRequest {
Expand Down Expand Up @@ -610,13 +632,14 @@ impl Searcher {
engines.retain(|engine| engine.is_applicable(&request.query));

// All providers share one deadline; completed results are retained.
// `try_from_secs_f64` keeps an unrepresentable configured value
// (settings validation normally rejects those) from panicking here.
let deadline = Instant::now()
+ Duration::from_secs_f64(
request
.timeout_limit
.unwrap_or(self.settings.search.timeout_limit)
+ Duration::try_from_secs_f64(
resolve_timeout_limit(request.timeout_limit, self.settings.search.timeout_limit)
.max(0.1),
);
)
.unwrap_or(Duration::from_millis(100));
// A general search that names a specialised source ("... arxiv",
// "... github") also asks that source, without the site's name.
let routed = self.site_routes(&request, &engines);
Expand Down Expand Up @@ -2373,6 +2396,39 @@ mod tests {

use super::*;

#[test]
fn timeout_limit_validation_rejects_non_finite_and_out_of_range_values() {
assert!(is_valid_timeout_limit(3.0));
assert!(is_valid_timeout_limit(MAX_TIMEOUT_LIMIT));
for value in [
f64::INFINITY,
f64::NEG_INFINITY,
f64::NAN,
0.0,
-1.0,
MAX_TIMEOUT_LIMIT + 0.1,
1e300,
] {
assert!(
!is_valid_timeout_limit(value),
"timeout_limit {value} must be rejected"
);
}
}

#[test]
fn invalid_timeout_limit_overrides_fall_back_to_the_configured_value() {
assert_eq!(resolve_timeout_limit(Some(2.5), 3.0), 2.5);
assert_eq!(resolve_timeout_limit(None, 3.0), 3.0);
for value in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 0.0, -1.0] {
assert_eq!(
resolve_timeout_limit(Some(value), 3.0),
3.0,
"timeout_limit {value} must fall back"
);
}
}

#[test]
fn cosine_similarity_behaves_as_expected() {
assert!((dot(&[1.0f32, 0.0], &[1.0f32, 0.0]) - 1.0).abs() < 1e-6);
Expand Down
26 changes: 26 additions & 0 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,14 @@ impl Settings {
"fetch.timeout must be a finite positive duration".into(),
));
}
if !self.search.timeout_limit.is_finite()
|| self.search.timeout_limit <= 0.0
|| std::time::Duration::try_from_secs_f64(self.search.timeout_limit).is_err()
{
return Err(SettingsError::Validation(
"search.timeout_limit must be a finite positive duration".into(),
));
}
if self.outgoing.request_timeout <= 0.0 || self.outgoing.max_request_timeout <= 0.0 {
return Err(SettingsError::Validation(
"outgoing timeouts must be positive".into(),
Expand Down Expand Up @@ -1963,6 +1971,24 @@ engines:
}
}

#[test]
fn search_timeout_limit_must_be_finite_and_representable() {
for (name, value) in [
("zero", "0.0"),
("negative", "-1.0"),
("infinity", "inf"),
("negative infinity", "-inf"),
("not a number", "nan"),
("out of range", "1e300"),
] {
let settings = merged_toml(&format!("[search]\ntimeout_limit = {value}\n"));
assert!(
settings.validate().is_err(),
"search.timeout_limit = {value} ({name}) must be rejected"
);
}
}

#[test]
fn deepseek_engine_is_disabled_by_default() {
let settings = Settings::defaults();
Expand Down
18 changes: 17 additions & 1 deletion src/web/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,23 @@ async fn run_search(
query.pageno = params.pageno.unwrap_or(1).max(1);
query.time_range = params.time_range.as_deref().and_then(TimeRange::parse);
query.safesearch = params.safesearch.unwrap_or(preferences.safesearch).min(2);
query.timeout_limit = params.timeout_limit;
if let Some(timeout_limit) = params.timeout_limit {
if !crate::aggregator::is_valid_timeout_limit(timeout_limit) {
let message = format!(
"timeout_limit must be a finite number of seconds greater than 0 and at most {}",
crate::aggregator::MAX_TIMEOUT_LIMIT
);
if format == "json" {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": message})),
)
.into_response();
}
return error_page(&state, StatusCode::BAD_REQUEST, &message);
}
query.timeout_limit = Some(timeout_limit);
}

let categories = split_param(params.categories.as_deref())
.or_else(|| split_param(params.category.as_deref()))
Expand Down
Loading