fix(finance): one timeout budget for quote and chart fallback (Audit finding D03-m3, partial)

The chart fallback got a fresh full timeout after the quote attempt, so
a lookup could take twice timeout_ms while the Timeout error reported
timeout_ms. A deadline now covers both attempts; the fallback gets what
is left, or records a timeout without firing. The schema says so.

Not changed: prices stay f64. JSON cannot carry NaN/inf and an
overflowing literal is a parse error (new test pins it); a derived
non-finite change_percent serializes as null. Decimal presentation and a
staleness policy for market_time are product decisions, left open.

Evidence (finance_fallback_shares_one_timeout_budget):
- production reverted: test result: FAILED. 1 passed; 10 failed; 0 ignored; 0 measured; 14431 filtered out; finished in 5.15s (all lane proof tests; the 1 pass is the finance overflow-literal disproval test)
  failure: the fallback answered on its second budget (Ok result with price 260.48)
- restored (cmp identical): test result: ok. 374 passed; 0 failed; 0 ignored; 0 measured; 14068 filtered out; finished in 2.95s (lane filter set, all new tests included)
finance_rejects_an_overflowing_price_literal passes both ways (disproval).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 12:39:20 -07:00
1 parent aa64f7d642
commit 7c36620d4f
1 file changed
+94 -2
+94 -2
View File
@@ -210,7 +210,7 @@ impl ToolSpec for FinanceTool {
},
"timeout_ms": {
"type": "integer",
"description": "Request timeout in milliseconds (default: 10000, max: 60000)."
"description": "Total lookup timeout in milliseconds, shared by the quote request and its chart fallback (default: 10000, max: 60000)."
}
},
"anyOf": [
@@ -260,6 +260,11 @@ impl ToolSpec for FinanceTool {
// cannot leak a request through the chart fallback.
check_network_policy(context, &self.endpoints)?;
// One budget covers the quote request and its chart fallback
// (#6557 D03-m3): the fallback gets only what the first attempt left,
// so `timeout_ms` — and the Timeout error that reports it — is the
// real wall-clock bound rather than half of it.
let deadline = std::time::Instant::now() + timeout;
let quote_result =
fetch_quote_endpoint(&self.client, timeout, &self.endpoints, &request).await;
match quote_result {
@@ -267,7 +272,13 @@ impl ToolSpec for FinanceTool {
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
}
Err(first_failure) => {
match fetch_chart_endpoint(&self.client, timeout, &self.endpoints, &request).await {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let chart_result = if remaining.is_zero() {
Err(AttemptFailure::timeout(CHART_SOURCE))
} else {
fetch_chart_endpoint(&self.client, remaining, &self.endpoints, &request).await
};
match chart_result {
Ok(result) => ToolResult::json(&result)
.map_err(|e| ToolError::execution_failed(e.to_string())),
Err(second_failure) => Err(finalize_failure(
@@ -938,6 +949,87 @@ mod tests {
assert!(matches!(err, ToolError::Timeout { .. }));
}
/// #6557 D03-m3: the chart fallback used to get a fresh full timeout, so
/// a lookup could run for twice `timeout_ms`. Here the quote attempt
/// spends the whole budget; the fallback, which would answer in 150ms,
/// must not be given a second budget.
#[tokio::test]
async fn finance_fallback_shares_one_timeout_budget() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/quote"))
.and(query_param("symbols", "AAPL"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(3_000))
.set_body_json(json!({"quoteResponse": {"result": []}})),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/chart/AAPL"))
.and(query_param("interval", "1d"))
.and(query_param("range", "5d"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(150))
.set_body_json(json!({
"chart": {
"result": [{
"meta": {
"symbol": "AAPL",
"regularMarketPrice": 260.48,
"chartPreviousClose": 255.92
}
}],
"error": null
}
})),
)
.mount(&server)
.await;
let tool = tool_with_server(&server);
let started = std::time::Instant::now();
let err = tool
.execute(json!({"ticker": "AAPL", "timeout_ms": 500}), &context().0)
.await
.expect_err("the shared budget is spent before the fallback can answer");
assert!(matches!(err, ToolError::Timeout { seconds: 1 }), "{err:?}");
assert!(
started.elapsed() < Duration::from_millis(2_500),
"lookup overran its budget: {:?}",
started.elapsed()
);
}
/// JSON cannot carry NaN or infinity, and an overflowing literal is a
/// parse error rather than an infinite price.
#[tokio::test]
async fn finance_rejects_an_overflowing_price_literal() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/quote"))
.and(query_param("symbols", "AAPL"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"quoteResponse":{"result":[{"symbol":"AAPL","regularMarketPrice":1e400}]}}"#,
"application/json",
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/chart/AAPL"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let err = tool_with_server(&server)
.execute(json!({"ticker": "AAPL"}), &context().0)
.await
.expect_err("an overflowing price is not a quote");
assert!(err.to_string().contains("invalid JSON response"), "{err}");
}
#[tokio::test]
async fn finance_prefers_timeout_over_unknown_symbol_when_any_attempt_times_out() {
let server = MockServer::start().await;