mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
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:
1 parent
aa64f7d642
commit
7c36620d4f
1 file changed
+94
-2
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user