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
3 changes: 3 additions & 0 deletions crates/fmp-py/python/fmp/_native/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ class FmpClient:
the default host the constructor raises `FmpConfigError` naming
`FMP_API_KEY`.
`timeout` and `connect_timeout` are positive finite numbers of seconds.
When `timeout` is omitted, `client.bulk` methods may take up to 600
seconds and every other method up to 30; a given `timeout` applies to
every method, bulk included.
`max_response_body_bytes` bounds each buffered response; when omitted,
`client.bulk` methods buffer up to 256 MiB and every other method up to
64 MiB. Authenticated
Expand Down
3 changes: 3 additions & 0 deletions crates/fmp-py/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ fn positive_duration(value: f64, field: &'static str) -> PyResult<Duration> {
/// the default host the constructor raises `FmpConfigError` naming
/// `FMP_API_KEY`.
/// `timeout` and `connect_timeout` are positive finite numbers of seconds.
/// When `timeout` is omitted, `client.bulk` methods may take up to 600
/// seconds and every other method up to 30; a given `timeout` applies to
/// every method, bulk included.
/// `max_response_body_bytes` bounds each buffered response; when omitted,
/// `client.bulk` methods buffer up to 256 MiB and every other method up to
/// 64 MiB. Authenticated
Expand Down
49 changes: 49 additions & 0 deletions crates/fmp-py/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Runtime contract of ``FmpClient`` and the ``client.quote`` namespace."""

import pickle
import threading
import time
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import MappingProxyType, SimpleNamespace
from typing import Any

Expand Down Expand Up @@ -284,6 +288,51 @@ def test_transport_error_is_structured(errors: SimpleNamespace) -> None:
assert (error.status, error.body, error.body_truncated) == (None, None, None)


class _SlowCsvHandler(BaseHTTPRequestHandler):
"""Answer every request with a header-only CSV body after a short delay."""

def do_GET(self) -> None:
"""Delay, then send the CSV header."""
time.sleep(1.0)
body = b"symbol\n"
self.send_response(200)
self.send_header("Content-Type", "text/csv")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def log_message(self, _format: str, *_args: Any) -> None:
"""Silence the default stderr access log."""


@pytest.fixture
def slow_csv_server() -> Iterator[str]:
"""A loopback server whose CSV answers take one second; yields its origin."""
server = ThreadingHTTPServer(("127.0.0.1", 0), _SlowCsvHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)


def test_explicit_timeout_applies_to_bulk_methods(slow_csv_server: str, errors: SimpleNamespace) -> None:
"""A given ``timeout`` bounds bulk calls too, despite their 600 s default."""
client = FmpClient(base_url=slow_csv_server, path_prefix="", auth_mode="none", timeout=0.2)
with pytest.raises(errors.FmpTransportError) as raised:
client.bulk.company_profiles("0")
assert raised.value.endpoint == "profile-bulk"


def test_bulk_methods_default_to_the_long_bulk_timeout(slow_csv_server: str) -> None:
"""Without ``timeout``, a slow bulk body still decodes (the 600 s bulk default)."""
client = FmpClient(base_url=slow_csv_server, path_prefix="", auth_mode="none")
assert client.bulk.company_profiles("0") == []


def test_status_error_redacts_configured_query_secret(fixture_server: FixtureServer, errors: SimpleNamespace) -> None:
"""The query credential never appears in a status error's body or repr."""
fixture_server.route(QUOTE_SHORT_PATH, b"denied?apikey=query-secret", status=401, content_type="text/plain")
Expand Down
57 changes: 40 additions & 17 deletions crates/libfmp/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,19 @@ use crate::{

pub use crate::endpoints::{EndpointSpec, QueryParameters};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
/// Default logical deadline for one call, redirects and body included (30 s).
///
/// It applies only while the client timeout is unset;
/// [`ClientBuilder::timeout`] replaces it for every endpoint.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

/// Default logical deadline for one CSV bulk call (600 s).
///
/// Whole-market bulk bodies arrive slowly: `profile-bulk` part 0 measured
/// 31.4 MB in 202 s on 2026-10-02. It applies only while the client timeout
/// is unset; [`ClientBuilder::timeout`] replaces it for every endpoint, bulk
/// included (ADR 0035).
pub const DEFAULT_BULK_TIMEOUT: Duration = Duration::from_secs(600);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Default maximum bytes buffered for each HTTP response (64 MiB).
///
Expand Down Expand Up @@ -72,7 +84,7 @@ pub struct ClientBuilder {
authentication_conflict: bool,
default_headers: Vec<(String, String)>,
user_agent: String,
timeout: Duration,
timeout: Option<Duration>,
connect_timeout: Duration,
max_response_body_bytes: Option<usize>,
danger_allow_insecure_authentication: bool,
Expand All @@ -90,7 +102,7 @@ impl Default for ClientBuilder {
authentication_conflict: false,
default_headers: Vec::new(),
user_agent: format!("libfmp/{VERSION}"),
timeout: DEFAULT_TIMEOUT,
timeout: None,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
max_response_body_bytes: None,
danger_allow_insecure_authentication: false,
Expand Down Expand Up @@ -170,9 +182,12 @@ impl ClientBuilder {
/// Sets one logical transport timeout across redirects and body buffering.
///
/// The client enforces this deadline around custom executors as well as the
/// built-in executor; individual redirect hops do not reset it.
/// built-in executor; individual redirect hops do not reset it. Until this
/// is set, each endpoint uses its own default: [`DEFAULT_TIMEOUT`], or
/// [`DEFAULT_BULK_TIMEOUT`] for CSV bulk routes. Once set, it applies to
/// every endpoint, bulk included.
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.timeout = Some(timeout);
self
}

Expand Down Expand Up @@ -270,14 +285,12 @@ impl ClientBuilder {
match self.executor {
Some(executor) => (executor, DeadlineBackend::Thread),
None => (
Arc::new(
ReqwestExecutor::new(self.timeout, self.connect_timeout).map_err(|_| {
Error::configuration_with_kind(
ConfigurationErrorKind::HttpClient,
"HTTP client could not be constructed",
)
})?,
),
Arc::new(ReqwestExecutor::new(self.connect_timeout).map_err(|_| {
Error::configuration_with_kind(
ConfigurationErrorKind::HttpClient,
"HTTP client could not be constructed",
)
})?),
DeadlineBackend::Tokio,
),
};
Expand Down Expand Up @@ -309,7 +322,7 @@ struct ClientInner {
redactor: Redactor,
executor: Arc<dyn HttpExecutor>,
deadline_backend: DeadlineBackend,
timeout: Duration,
timeout: Option<Duration>,
max_response_body_bytes: Option<usize>,
redirect_policy: RedirectPolicy,
}
Expand Down Expand Up @@ -386,10 +399,11 @@ impl Client {
redactor.add_secret(&SecretString::new((*value).to_owned()));
}

let timeout = self.inner.timeout.unwrap_or(endpoint.default_timeout());
with_timeout(
self.inner.deadline_backend,
self.inner.timeout,
self.execute_redirects(endpoint, url, headers, &redactor),
timeout,
self.execute_redirects(endpoint, url, headers, &redactor, timeout),
)
.await
.ok_or_else(|| Error::transport(Some(endpoint.id()), "request deadline exceeded"))?
Expand All @@ -401,6 +415,7 @@ impl Client {
mut url: Url,
headers: HeaderMap,
redactor: &Redactor,
timeout: Duration,
) -> Result<R> {
for redirect_count in 0..=MAX_REDIRECTS {
apply_query_auth(&mut url, self.inner.auth.query.as_ref());
Expand All @@ -416,6 +431,7 @@ impl Client {
url.clone(),
headers.clone(),
max_body_bytes,
timeout,
))
.await
.map_err(|_| Error::transport(Some(endpoint.id()), "request execution failed"))?;
Expand Down Expand Up @@ -1052,7 +1068,7 @@ mod tests {
let injected = Client::builder()
.base_url("https://example.test")
.executor(Arc::new(
ReqwestExecutor::new(DEFAULT_TIMEOUT, DEFAULT_CONNECT_TIMEOUT).unwrap(),
ReqwestExecutor::new(DEFAULT_CONNECT_TIMEOUT).unwrap(),
))
.build()
.unwrap();
Expand Down Expand Up @@ -1129,4 +1145,11 @@ mod tests {
assert_eq!(DEFAULT_BULK_MAX_RESPONSE_BODY_BYTES, 256 * 1024 * 1024);
assert_eq!(ClientBuilder::default().max_response_body_bytes, None);
}

#[test]
fn default_timeouts_are_the_documented_values() {
assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(30));
assert_eq!(DEFAULT_BULK_TIMEOUT, Duration::from_secs(600));
assert_eq!(ClientBuilder::default().timeout, None);
}
}
9 changes: 6 additions & 3 deletions crates/libfmp/src/endpoints/bulk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@
//! client limit is unset: whole-market bodies such as `ratios-ttm-bulk`
//! exceed the 64 MiB general default. A limit set with
//! [`crate::client::ClientBuilder::max_response_body_bytes`] applies to bulk
//! routes too, and every bulk call shares the client's single logical
//! timeout, which [`crate::client::ClientBuilder::timeout`] can raise. The
//! Python binding exposes the same eighteen methods under `client.bulk`.
//! routes too. The same rule holds for the call deadline: while the client
//! timeout is unset, bulk calls use
//! [`crate::client::DEFAULT_BULK_TIMEOUT`] (600 s) because whole-market bodies
//! arrive slowly, and every other route keeps the 30 s default. A timeout set
//! with [`crate::client::ClientBuilder::timeout`] applies to bulk routes too.
//! The Python binding exposes the same eighteen methods under `client.bulk`.

use crate::{
Client, Result,
Expand Down
15 changes: 14 additions & 1 deletion crates/libfmp/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub struct EndpointSpec<Q, R> {
metadata: EndpointMetadata,
max_response_body_bytes: Option<usize>,
default_max_response_body_bytes: usize,
default_timeout: std::time::Duration,
}

impl<Q, R> EndpointSpec<Q, R> {
Expand All @@ -81,6 +82,7 @@ impl<Q, R> EndpointSpec<Q, R> {
metadata: EndpointMetadata::new(),
max_response_body_bytes: None,
default_max_response_body_bytes: crate::client::DEFAULT_MAX_RESPONSE_BODY_BYTES,
default_timeout: crate::client::DEFAULT_TIMEOUT,
}
}

Expand Down Expand Up @@ -142,6 +144,14 @@ impl<Q, R> EndpointSpec<Q, R> {
pub const fn default_max_response_body_bytes(&self) -> usize {
self.default_max_response_body_bytes
}

/// Returns the call deadline used while the client timeout is unset.
///
/// This is [`crate::client::DEFAULT_TIMEOUT`] except for CSV bulk
/// descriptors, which use [`crate::client::DEFAULT_BULK_TIMEOUT`].
pub const fn default_timeout(&self) -> std::time::Duration {
self.default_timeout
}
}

impl<Q, R> EndpointSpec<Q, R>
Expand Down Expand Up @@ -181,7 +191,9 @@ where
///
/// Each record decodes into one `Row` by header name. Unless the endpoint
/// or the client sets a limit, the body may grow to
/// [`crate::client::DEFAULT_BULK_MAX_RESPONSE_BODY_BYTES`].
/// [`crate::client::DEFAULT_BULK_MAX_RESPONSE_BODY_BYTES`], and unless the
/// client sets a timeout, the call may take
/// [`crate::client::DEFAULT_BULK_TIMEOUT`].
pub const fn get_csv(id: &'static str, relative_path: &'static str, query: Q) -> Self {
let mut spec = Self::with_response(
HttpMethod::Get,
Expand All @@ -191,6 +203,7 @@ where
ResponseContract::csv(),
);
spec.default_max_response_body_bytes = crate::client::DEFAULT_BULK_MAX_RESPONSE_BODY_BYTES;
spec.default_timeout = crate::client::DEFAULT_BULK_TIMEOUT;
spec
}
}
Expand Down
15 changes: 13 additions & 2 deletions crates/libfmp/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub struct PreparedRequest {
url: Url,
headers: HeaderMap,
max_response_body_bytes: usize,
timeout: std::time::Duration,
}

impl PreparedRequest {
Expand All @@ -43,12 +44,14 @@ impl PreparedRequest {
url: Url,
headers: HeaderMap,
max_response_body_bytes: usize,
timeout: std::time::Duration,
) -> Self {
Self {
method,
url,
headers,
max_response_body_bytes,
timeout,
}
}

Expand Down Expand Up @@ -78,6 +81,15 @@ impl PreparedRequest {
pub fn max_response_body_bytes(&self) -> usize {
self.max_response_body_bytes
}

/// Returns the logical deadline of the call this request belongs to.
///
/// The client enforces it around every executor; an executor may also
/// apply it to its own transport. It spans the whole redirect chain, so
/// it is an upper bound for this one request.
pub fn timeout(&self) -> std::time::Duration {
self.timeout
}
}

impl fmt::Debug for PreparedRequest {
Expand Down Expand Up @@ -215,11 +227,9 @@ pub(crate) struct ReqwestExecutor {

impl ReqwestExecutor {
pub(crate) fn new(
timeout: std::time::Duration,
connect_timeout: std::time::Duration,
) -> std::result::Result<Self, ExecutorError> {
let client = reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(connect_timeout)
// Redirects are handled above the executor boundary so every auth
// mode follows exactly the same same-origin rule.
Expand All @@ -244,6 +254,7 @@ impl HttpExecutor for ReqwestExecutor {
.client
.request(request.method.into_reqwest(), request.url)
.headers(request.headers)
.timeout(request.timeout)
.send()
.await
.map_err(|_| ExecutorError::new())?;
Expand Down
Loading
Loading