diff --git a/crates/thetadatadx/src/config/flatfiles.rs b/crates/thetadatadx/src/config/flatfiles.rs index 7996b3479..f93a624cc 100644 --- a/crates/thetadatadx/src/config/flatfiles.rs +++ b/crates/thetadatadx/src/config/flatfiles.rs @@ -41,6 +41,27 @@ pub struct FlatFilesConfig { /// once, and an un-jittered ladder lands those retries in /// lockstep. Disable only for tests that assert exact timings. pub jitter: bool, + + /// TCP + TLS connect timeout for one legacy-host attempt, in seconds. + /// + /// Bounds the time spent establishing the connection and running the + /// auth handshake against a single host before the attempt is + /// abandoned and the next host (or the retry ladder) takes over. + /// Without it a host that accepts the TCP connection but never + /// completes the TLS handshake would hang the request indefinitely. + /// Mirrors the historical (gRPC) channel's `connect_timeout_secs`. + pub connect_timeout_secs: u64, + + /// Read timeout for a single FLAT_FILE response frame, in seconds. + /// + /// Bounds the wait for the next chunk once streaming has begun. A + /// server that stalls mid-stream — accepting the request but never + /// sending another frame or the FLAT_FILE_END terminator — would + /// otherwise block the download forever. On expiry the attempt fails + /// as a transient stall so the retry ladder reconnects on a fresh + /// session. Sized generously so a slow-but-progressing bulk transfer + /// is never cut off mid-chunk. + pub read_timeout_secs: u64, } impl FlatFilesConfig { @@ -57,6 +78,15 @@ impl FlatFilesConfig { initial_backoff: Duration::from_secs(1), max_backoff: Duration::from_secs(30), jitter: true, + // Matches the historical (gRPC) channel connect bound; long + // enough for a TLS handshake behind NAT / VPN, short enough + // that a black-holed host fails over fast. + connect_timeout_secs: 10, + // A FLAT_FILE stream sends frequent chunks while it is making + // progress; 60 s between frames is far beyond any healthy + // inter-chunk gap, so a longer silence means the server has + // stalled and the attempt should fail over rather than hang. + read_timeout_secs: 60, } } diff --git a/crates/thetadatadx/src/flatfiles/decoded_row.rs b/crates/thetadatadx/src/flatfiles/decoded_row.rs index 67dc840f4..bbf30c17b 100644 --- a/crates/thetadatadx/src/flatfiles/decoded_row.rs +++ b/crates/thetadatadx/src/flatfiles/decoded_row.rs @@ -87,9 +87,10 @@ impl FlatFileRow { Ok(Self { symbol: symbol.to_string(), expiration, - // Vendor fixed-point (1/1000 dollar) -> dollars at the - // typed boundary. - strike: strike.map(|s| f64::from(s) / 1000.0), + // Strikes are dollars on every client-facing surface; the + // shared conversion keeps CSV / JSONL / Arrow / typed-row in + // lockstep on the exact value. + strike: crate::flatfiles::index::strike_dollars(strike), right, fields, }) diff --git a/crates/thetadatadx/src/flatfiles/index.rs b/crates/thetadatadx/src/flatfiles/index.rs index 854435b91..89dc57a6e 100644 --- a/crates/thetadatadx/src/flatfiles/index.rs +++ b/crates/thetadatadx/src/flatfiles/index.rs @@ -104,6 +104,23 @@ pub(crate) fn parse_header(blob: &[u8]) -> Result { }) } +/// Divisor that converts the vendor wire strike (tenths of a cent) into +/// dollars. `210000` wire units ≡ `$210.00`. Invariant: strikes are +/// always dollars on every client-facing surface (CSV, JSONL, Arrow, and +/// the typed [`crate::flatfiles::decoded_row::FlatFileRow`]); the scaled +/// integer wire form must never reach a caller. Centralised here so the +/// scale lives in exactly one place across all output paths. +pub(crate) const STRIKE_WIRE_PER_DOLLAR: f64 = 1000.0; + +/// Convert a wire strike (tenths of a cent) to dollars. +/// +/// Returns `None` for stock entries, which carry no strike. Shared by +/// every output path so CSV, JSONL, Arrow, and the typed row agree on the +/// exact value for the same input. +pub(crate) fn strike_dollars(strike: Option) -> Option { + strike.map(|s| f64::from(s) / STRIKE_WIRE_PER_DOLLAR) +} + /// One INDEX entry's contract key + DATA-section pointer. #[derive(Debug, Clone)] pub(crate) struct IndexEntry { @@ -121,6 +138,14 @@ pub(crate) struct IndexEntry { pub(crate) block_end: u64, } +impl IndexEntry { + /// This entry's strike in dollars, or `None` for stock entries. See + /// [`strike_dollars`] for the shared conversion every output path uses. + pub(crate) fn strike_dollars(&self) -> Option { + strike_dollars(self.strike) + } +} + /// Iterator over INDEX entries. /// /// Holds a borrowed slice of just the INDEX section bytes (i.e. diff --git a/crates/thetadatadx/src/flatfiles/request.rs b/crates/thetadatadx/src/flatfiles/request.rs index 9be01c356..edf42da12 100644 --- a/crates/thetadatadx/src/flatfiles/request.rs +++ b/crates/thetadatadx/src/flatfiles/request.rs @@ -193,7 +193,7 @@ pub async fn flatfile_request_raw_with_config( run_retry_loop(config, move |_attempt| { let creds = creds.clone(); let path = output_for_attempt.clone(); - async move { run_one_attempt(&creds, sec, req, date, &path).await } + async move { run_one_attempt(&creds, sec, req, date, &path, config).await } }) .await } @@ -257,6 +257,7 @@ async fn run_one_attempt( req: ReqType, date: &str, output_path: &Path, + config: &FlatFilesConfig, ) -> Result { // Build the host candidate list — try every (host, port) in priority // order, matching the vendor terminal's `MDDS_NJ_HOSTS` config. @@ -268,7 +269,9 @@ async fn run_one_attempt( } } - let mut session = connect_and_login(&hosts, creds).await?; + let connect_timeout = std::time::Duration::from_secs(config.connect_timeout_secs.max(1)); + let read_timeout = std::time::Duration::from_secs(config.read_timeout_secs.max(1)); + let mut session = connect_and_login(&hosts, creds, connect_timeout).await?; tracing::debug!(target: "flatfiles", "authed against MDDS legacy: bundle={}", session.bundle); let request_id = next_id(); @@ -292,9 +295,22 @@ async fn run_one_attempt( // clean end-of-stream, no bookkeeping flag needed. loop { - let frame = match read_frame(&mut session.stream).await { - Ok(f) => f, - Err(e) => { + // Bound the wait for each frame. A server that stalls mid-stream + // — never sending the next chunk nor FLAT_FILE_END — would + // otherwise hang the download forever. On expiry, surface a + // transient stall so the retry ladder reconnects on a fresh + // session rather than blocking indefinitely. + let frame = match tokio::time::timeout(read_timeout, read_frame(&mut session.stream)).await + { + Err(_) => { + return Err(Error::FlatFilesUnavailable( + FlatFilesUnavailableReason::StreamTruncated { + bytes_received: total, + }, + )); + } + Ok(Ok(f)) => f, + Ok(Err(e)) => { // Differentiate between EOF-after-some-data (truncation) // and an outright protocol error. if total > 0 @@ -442,6 +458,7 @@ mod tests { initial_backoff: std::time::Duration::from_millis(1), max_backoff: std::time::Duration::from_millis(4), jitter: false, + ..FlatFilesConfig::production_defaults() } } diff --git a/crates/thetadatadx/src/flatfiles/session.rs b/crates/thetadatadx/src/flatfiles/session.rs index cdfba5d3c..4dce6b4c4 100644 --- a/crates/thetadatadx/src/flatfiles/session.rs +++ b/crates/thetadatadx/src/flatfiles/session.rs @@ -13,6 +13,7 @@ //! from the METADATA payload. use std::sync::Arc; +use std::time::Duration; use rustls::pki_types::ServerName; use rustls::ClientConfig; @@ -172,24 +173,47 @@ pub(crate) async fn login( /// short-circuited: replaying it across every MDDS host is pointless, /// risks rate-limiting the account, and the original error already /// describes what the server objected to. +/// +/// `connect_timeout` bounds the combined TCP + TLS handshake **and** auth +/// exchange for a single host. A host that accepts the socket but never +/// finishes the TLS handshake, or never returns the auth frames, would +/// otherwise block the whole request forever; on expiry the attempt +/// moves on to the next host (or surfaces a transient timeout the retry +/// ladder reconnects on). pub(crate) async fn connect_and_login<'a>( hosts: &[MddsHost<'a>], creds: &Credentials, + connect_timeout: Duration, ) -> Result { let mut last_err: Option = None; for host in hosts { - match connect_tls(MddsHost { - host: host.host, - port: host.port, - }) - .await - { - Ok(mut stream) => match login(&mut stream, creds).await { - Ok(bundle) => return Ok(AuthedSession { stream, bundle }), - Err(e) if is_terminal_login_error(&e) => return Err(e), - Err(e) => last_err = Some(e), - }, - Err(e) => last_err = Some(e), + let attempt = async { + let mut stream = connect_tls(MddsHost { + host: host.host, + port: host.port, + }) + .await?; + let bundle = login(&mut stream, creds).await?; + Ok::(AuthedSession { stream, bundle }) + }; + match tokio::time::timeout(connect_timeout, attempt).await { + Ok(Ok(session)) => return Ok(session), + Ok(Err(e)) if is_terminal_login_error(&e) => return Err(e), + Ok(Err(e)) => last_err = Some(e), + Err(_) => { + // Treated as a transient I/O timeout so the retry ladder + // reconnects; a stuck handshake on one host should not + // fail the whole request without a reconnect attempt. + last_err = Some(Error::Io(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "MDDS connect/login to {}:{} timed out after {}s", + host.host, + host.port, + connect_timeout.as_secs() + ), + ))); + } } } Err(last_err.unwrap_or_else(|| Error::config_missing("mdds.hosts"))) @@ -224,4 +248,47 @@ mod tests { // first character of the JSON body must be '{'. assert_eq!(p[4], b'{'); } + + #[tokio::test] + async fn connect_and_login_times_out_on_unreachable_host() { + use crate::auth::Credentials; + use std::time::Instant; + + // 192.0.2.1 is TEST-NET-1 (RFC 5737): guaranteed non-routable, so + // the TCP connect stalls until our timeout fires rather than + // failing fast. Without the connect bound this call would hang. + let hosts = [MddsHost { + host: "192.0.2.1", + port: 12_000, + }]; + let creds = Credentials::new("user@example.com", "pw"); + let budget = Duration::from_millis(150); + let started = Instant::now(); + let result = connect_and_login(&hosts, &creds, budget).await; + let elapsed = started.elapsed(); + + // `AuthedSession` is not `Debug`; match the variants by hand so a + // success path fails the test without needing a `Debug` bound. + let err = match result { + Ok(_) => panic!("connect to a black-hole host must fail, not succeed"), + Err(e) => e, + }; + // The bound must actually fire — a hung host cannot block forever. + assert!( + elapsed < Duration::from_secs(5), + "connect must abandon the host near its timeout, took {elapsed:?}" + ); + // Classified transient so the retry ladder reconnects. + assert!( + error_is_transient_for_test(&err), + "connect timeout must be transient, got {err:?}" + ); + } + + /// Mirror of the request-layer transient classifier for the single + /// case this test exercises: a timed-out connect surfaces as + /// `Error::Io`, which the retry loop treats as transient. + fn error_is_transient_for_test(err: &Error) -> bool { + matches!(err, Error::Io(_)) + } } diff --git a/crates/thetadatadx/src/flatfiles/writer.rs b/crates/thetadatadx/src/flatfiles/writer.rs index 29ab154f2..826171d1a 100644 --- a/crates/thetadatadx/src/flatfiles/writer.rs +++ b/crates/thetadatadx/src/flatfiles/writer.rs @@ -132,7 +132,11 @@ fn append_csv_prefix(buf: &mut String, entry: &IndexEntry, sec: SecType) { buf.push(','); let _ = write!(buf, "{}", entry.expiration.unwrap_or(0)); buf.push(','); - let _ = write!(buf, "{}", entry.strike.unwrap_or(0)); + // Strikes are dollars on every client-facing surface — emit + // the same dollar value the Arrow and typed-row paths do, via + // the shared conversion. `f64` Display preserves sub-dollar + // strikes without trailing-zero noise. + let _ = write!(buf, "{}", entry.strike_dollars().unwrap_or(0.0)); buf.push(','); buf.push(entry.right.unwrap_or('?')); buf.push(','); @@ -292,9 +296,17 @@ impl RowSink for JsonlSink { "expiration".into(), serde_json::Value::Number(row.entry.expiration.unwrap_or(0).into()), ); + // Strikes are dollars on every client-facing surface; + // emit the same dollar value as the Arrow and typed-row + // paths via the shared conversion. A non-finite f64 cannot + // arise from an i32/1000 quotient, but `from_f64` is the + // only fallible step, so fall back to JSON null defensively. + let strike_dollars = row.entry.strike_dollars().unwrap_or(0.0); obj.insert( "strike".into(), - serde_json::Value::Number(row.entry.strike.unwrap_or(0).into()), + serde_json::Number::from_f64(strike_dollars) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), ); obj.insert( "right".into(), @@ -510,6 +522,155 @@ mod tests { assert!(res.is_err()); } + /// Read back the strike value the CSV sink wrote for `entry`. CSV + /// strike is the 3rd field: `symbol,expiration,strike,right,...`. + fn csv_strike_for(entry: &IndexEntry, fmt: &[DataType], row_data: &[i32]) -> f64 { + let path = scratch_path(); + let mut csv = + CsvSink::new(&path, SecType::Option, fmt.to_vec(), Some(2)).expect("CsvSink::new"); + csv.write_header().expect("csv header"); + csv.write_row(RowView { + entry, + data: row_data, + }) + .expect("csv row"); + Box::new(csv).finish().expect("csv finish"); + let text = std::fs::read_to_string(&path).expect("read csv"); + text.lines() + .nth(1) + .expect("csv data row") + .split(',') + .nth(2) + .expect("csv strike field") + .parse() + .expect("csv strike parses as f64") + } + + /// Read back the strike value the JSONL sink wrote for `entry`. + fn jsonl_strike_for(entry: &IndexEntry, fmt: &[DataType], row_data: &[i32]) -> f64 { + let path = scratch_path(); + let mut jsonl = + JsonlSink::new(&path, SecType::Option, fmt.to_vec(), Some(2)).expect("JsonlSink::new"); + jsonl.write_header().expect("jsonl header"); + jsonl + .write_row(RowView { + entry, + data: row_data, + }) + .expect("jsonl row"); + Box::new(jsonl).finish().expect("jsonl finish"); + let text = std::fs::read_to_string(&path).expect("read jsonl"); + let parsed: serde_json::Value = + serde_json::from_str(text.lines().next().expect("jsonl line")).expect("jsonl parses"); + parsed["strike"].as_f64().expect("jsonl strike is a number") + } + + #[test] + fn strike_is_dollars_and_identical_across_csv_and_jsonl() { + let fmt = vec![ + DataType::MsOfDay, + DataType::Bid, + DataType::PriceType, + DataType::Date, + ]; + let row_data = [34_200_000_i32, 15_025, 8, 20_250_428]; + // Whole-dollar and sub-dollar wire strikes (tenths of a cent). + // 580000 -> $580.00, 1500 -> $1.50. + for (wire_strike, expected_dollars) in [(580_000_i32, 580.0_f64), (1_500_i32, 1.5_f64)] { + let entry = IndexEntry { + symbol: "SPX".into(), + expiration: Some(20_260_516), + strike: Some(wire_strike), + right: Some('C'), + block_start: 0, + block_end: 0, + }; + let csv_strike = csv_strike_for(&entry, &fmt, &row_data); + let jsonl_strike = jsonl_strike_for(&entry, &fmt, &row_data); + assert!( + (csv_strike - expected_dollars).abs() < 1e-9, + "CSV strike must be dollars: got {csv_strike}, want {expected_dollars}" + ); + assert!( + (jsonl_strike - expected_dollars).abs() < 1e-9, + "JSONL strike must be dollars: got {jsonl_strike}, want {expected_dollars}" + ); + assert_eq!( + csv_strike, jsonl_strike, + "CSV and JSONL strike must be identical" + ); + } + } + + #[cfg(feature = "arrow")] + #[test] + fn strike_is_dollars_and_identical_across_csv_jsonl_arrow() { + use crate::flatfiles::arrow::rows_to_arrow; + use crate::flatfiles::decoded_row::FlatFileRow; + use arrow_array::cast::AsArray; + use arrow_array::types::Float64Type; + + // Schema with a PRICE_TYPE column so every surface decodes prices + // the same way; the strike assertion is independent of price. + let fmt = vec![ + DataType::MsOfDay, + DataType::Bid, + DataType::PriceType, + DataType::Date, + ]; + let data_idx = data_indices(&fmt, Some(2)); + let row_data = [34_200_000_i32, 15_025, 8, 20_250_428]; + + // Whole-dollar and sub-dollar wire strikes (tenths of a cent). + // 580000 -> $580.00, 1500 -> $1.50. + for (wire_strike, expected_dollars) in [(580_000_i32, 580.0_f64), (1_500_i32, 1.5_f64)] { + let entry = IndexEntry { + symbol: "SPX".into(), + expiration: Some(20_260_516), + strike: Some(wire_strike), + right: Some('C'), + block_start: 0, + block_end: 0, + }; + + let csv_strike = csv_strike_for(&entry, &fmt, &row_data); + let jsonl_strike = jsonl_strike_for(&entry, &fmt, &row_data); + + // Arrow: strike column is Float64 dollars. + let typed_row = FlatFileRow::from_decoded( + &entry.symbol, + entry.expiration, + entry.strike, + entry.right, + &fmt, + &row_data, + &data_idx, + Some(row_data[2]), + ) + .expect("from_decoded"); + let batch = rows_to_arrow(std::slice::from_ref(&typed_row)).expect("rows_to_arrow"); + let strike_col = batch + .column_by_name("strike") + .expect("strike column") + .as_primitive::(); + let arrow_strike = strike_col.value(0); + assert!( + (arrow_strike - expected_dollars).abs() < 1e-9, + "Arrow strike must be dollars: got {arrow_strike}, want {expected_dollars}" + ); + + // The load-bearing invariant: all three surfaces agree exactly. + assert_eq!( + csv_strike, jsonl_strike, + "CSV and JSONL strike must be identical" + ); + assert_eq!( + csv_strike, arrow_strike, + "CSV and Arrow strike must be identical" + ); + } + } + #[test] fn csv_sink_write_row_smoke_with_in_range_price_type() { let tmp = scratch_path(); diff --git a/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs b/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs index 2d66f49d3..46e65b5d7 100644 --- a/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs +++ b/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs @@ -122,16 +122,18 @@ fn synthetic_option_blob() -> Vec { /// Manual derivation: /// - header columns (price_type column suppressed by the writer): /// `symbol,expiration,strike,right,ms_of_day,bid,date`. -/// - row 1: contract prefix `SPY,20240315,580000,C` plus -/// `34200000,123.45,20240315`. Bid=12345 with price_type=8 → +/// - row 1: contract prefix `SPY,20240315,580,C` plus +/// `34200000,123.45,20240315`. The wire strike of 580000 thousandths +/// renders as 580 dollars, the same unit every other output surface +/// emits. Bid=12345 with price_type=8 → /// `12345 / 10^(10-8) = 12345 / 100 = 123.45`. /// - row 2: contract prefix is the same; ms_of_day delta +100 → /// 34200100; bid delta +5 → 12350 → 123.5 (Rust f64 Display drops /// the trailing zero); date carried forward → 20240315. const EXPECTED_CSV: &str = "\ symbol,expiration,strike,right,ms_of_day,bid,date -SPY,20240315,580000,C,34200000,123.45,20240315 -SPY,20240315,580000,C,34200100,123.5,20240315 +SPY,20240315,580,C,34200000,123.45,20240315 +SPY,20240315,580,C,34200100,123.5,20240315 "; #[test] diff --git a/ffi/src/auth.rs b/ffi/src/auth.rs index 72ce7adfa..6ab4a8c72 100644 --- a/ffi/src/auth.rs +++ b/ffi/src/auth.rs @@ -2146,6 +2146,82 @@ pub unsafe extern "C" fn thetadatadx_config_get_flatfiles_max_backoff_secs( }) } +/// Set the TCP + TLS connect timeout (seconds) for one flatfile-host +/// attempt. Bounds the connect/auth handshake before the attempt is +/// abandoned and the next host (or the retry ladder) takes over. +/// Default `10`. +#[no_mangle] +pub unsafe extern "C" fn thetadatadx_config_set_flatfiles_connect_timeout_secs( + config: *mut ThetaDataDxConfig, + secs: u64, +) { + ffi_boundary!((), { + let config = require_config_mut!(config); + config.inner.flatfiles.connect_timeout_secs = secs; + }) +} + +/// Read the current `flatfiles.connect_timeout_secs` setting (seconds). +/// Returns `0` on success, `-1` if either pointer is null. +#[no_mangle] +pub unsafe extern "C" fn thetadatadx_config_get_flatfiles_connect_timeout_secs( + config: *const ThetaDataDxConfig, + out_secs: *mut u64, +) -> i32 { + ffi_boundary!(-1, { + if config.is_null() || out_secs.is_null() { + set_error("config or out-parameter pointer is null"); + return -1; + } + // SAFETY: config is a non-null `*const ThetaDataDxConfig` returned by `thetadatadx_config_*` and not yet freed; `&*` produces a shared reference valid for the call duration. + let config = unsafe { &*config }; + // SAFETY: out_secs null-checked above. The field is a `u64`, so + // the write is layout-compatible with the caller-pinned storage. + unsafe { + *out_secs = config.inner.flatfiles.connect_timeout_secs; + } + 0 + }) +} + +/// Set the read timeout (seconds) for a single flatfile response frame. +/// Bounds the wait for the next chunk once streaming has begun so a +/// mid-stream stall fails over instead of blocking forever. Default +/// `60`. +#[no_mangle] +pub unsafe extern "C" fn thetadatadx_config_set_flatfiles_read_timeout_secs( + config: *mut ThetaDataDxConfig, + secs: u64, +) { + ffi_boundary!((), { + let config = require_config_mut!(config); + config.inner.flatfiles.read_timeout_secs = secs; + }) +} + +/// Read the current `flatfiles.read_timeout_secs` setting (seconds). +/// Returns `0` on success, `-1` if either pointer is null. +#[no_mangle] +pub unsafe extern "C" fn thetadatadx_config_get_flatfiles_read_timeout_secs( + config: *const ThetaDataDxConfig, + out_secs: *mut u64, +) -> i32 { + ffi_boundary!(-1, { + if config.is_null() || out_secs.is_null() { + set_error("config or out-parameter pointer is null"); + return -1; + } + // SAFETY: config is a non-null `*const ThetaDataDxConfig` returned by `thetadatadx_config_*` and not yet freed; `&*` produces a shared reference valid for the call duration. + let config = unsafe { &*config }; + // SAFETY: out_secs null-checked above. The field is a `u64`, so + // the write is layout-compatible with the caller-pinned storage. + unsafe { + *out_secs = config.inner.flatfiles.read_timeout_secs; + } + 0 + }) +} + // ── AuthConfig ───────────────────────────────────────────────────── // // Per-field setters/getters on `DirectConfig.auth`. Both fields are @@ -3350,6 +3426,56 @@ mod flatfiles_setter_tests { } } + #[test] + fn flatfiles_connect_timeout_secs_round_trips() { + let cfg = super::thetadatadx_config_production(); + // SAFETY: handle just returned by thetadatadx_config_production. + unsafe { + let mut got: u64 = 0; + // Default seeded from FlatFilesConfig::production_defaults(). + assert_eq!( + super::thetadatadx_config_get_flatfiles_connect_timeout_secs(cfg, &mut got), + 0 + ); + assert_eq!(got, 10); + for secs in [0u64, 1, 4, 10, 60, 3600] { + super::thetadatadx_config_set_flatfiles_connect_timeout_secs(cfg, secs); + assert_eq!((*cfg).inner.flatfiles.connect_timeout_secs, secs); + assert_eq!( + super::thetadatadx_config_get_flatfiles_connect_timeout_secs(cfg, &mut got), + 0 + ); + assert_eq!(got, secs); + } + super::thetadatadx_config_free(cfg); + } + } + + #[test] + fn flatfiles_read_timeout_secs_round_trips() { + let cfg = super::thetadatadx_config_production(); + // SAFETY: handle just returned by thetadatadx_config_production. + unsafe { + let mut got: u64 = 0; + // Default seeded from FlatFilesConfig::production_defaults(). + assert_eq!( + super::thetadatadx_config_get_flatfiles_read_timeout_secs(cfg, &mut got), + 0 + ); + assert_eq!(got, 60); + for secs in [0u64, 1, 4, 10, 60, 3600, 86_400] { + super::thetadatadx_config_set_flatfiles_read_timeout_secs(cfg, secs); + assert_eq!((*cfg).inner.flatfiles.read_timeout_secs, secs); + assert_eq!( + super::thetadatadx_config_get_flatfiles_read_timeout_secs(cfg, &mut got), + 0 + ); + assert_eq!(got, secs); + } + super::thetadatadx_config_free(cfg); + } + } + #[test] fn flatfiles_setters_null_handle_returns_minus_one_or_noop() { // SAFETY: passing null to thetadatadx_config_* is the documented FFI @@ -3358,6 +3484,8 @@ mod flatfiles_setter_tests { super::thetadatadx_config_set_flatfiles_max_attempts(std::ptr::null_mut(), 3); super::thetadatadx_config_set_flatfiles_initial_backoff_secs(std::ptr::null_mut(), 1); super::thetadatadx_config_set_flatfiles_max_backoff_secs(std::ptr::null_mut(), 4); + super::thetadatadx_config_set_flatfiles_connect_timeout_secs(std::ptr::null_mut(), 10); + super::thetadatadx_config_set_flatfiles_read_timeout_secs(std::ptr::null_mut(), 60); let mut got_n: u32 = 0; let mut got_secs: u64 = 0; assert_eq!( @@ -3378,6 +3506,20 @@ mod flatfiles_setter_tests { ), -1 ); + assert_eq!( + super::thetadatadx_config_get_flatfiles_connect_timeout_secs( + std::ptr::null(), + &mut got_secs + ), + -1 + ); + assert_eq!( + super::thetadatadx_config_get_flatfiles_read_timeout_secs( + std::ptr::null(), + &mut got_secs + ), + -1 + ); } } @@ -3392,10 +3534,14 @@ mod flatfiles_setter_tests { super::thetadatadx_config_set_flatfiles_max_attempts(cfg, 5); super::thetadatadx_config_set_flatfiles_initial_backoff_secs(cfg, 2); super::thetadatadx_config_set_flatfiles_max_backoff_secs(cfg, 30); + super::thetadatadx_config_set_flatfiles_connect_timeout_secs(cfg, 15); + super::thetadatadx_config_set_flatfiles_read_timeout_secs(cfg, 90); let ff = &(*cfg).inner.flatfiles; assert_eq!(ff.max_attempts, 5); assert_eq!(ff.initial_backoff, std::time::Duration::from_secs(2)); assert_eq!(ff.max_backoff, std::time::Duration::from_secs(30)); + assert_eq!(ff.connect_timeout_secs, 15); + assert_eq!(ff.read_timeout_secs, 90); super::thetadatadx_config_free(cfg); } } diff --git a/scripts/check_doc_defaults.py b/scripts/check_doc_defaults.py index 628683aab..290ba8d53 100644 --- a/scripts/check_doc_defaults.py +++ b/scripts/check_doc_defaults.py @@ -178,6 +178,8 @@ def load_canonical(root: pathlib.Path = REPO_ROOT) -> dict[str, int]: canon["flatfiles.max_attempts"] = _norm_int(ff["max_attempts"]) canon["flatfiles.initial_backoff_secs"] = _duration_secs(ff["initial_backoff"]) canon["flatfiles.max_backoff_secs"] = _duration_secs(ff["max_backoff"]) + canon["flatfiles.connect_timeout_secs"] = _norm_int(ff["connect_timeout_secs"]) + canon["flatfiles.read_timeout_secs"] = _norm_int(ff["read_timeout_secs"]) # FlatFilesConfig bounds — `MAX_ATTEMPTS: ... = 1..=100`. ff_src = _read(CONFIG_DIR / "flatfiles.rs", root) @@ -543,6 +545,14 @@ def build_surfaces() -> list[Surface]: SurfaceField( "flatfiles.max_backoff_secs", _re(r"set_flatfiles_max_backoff_secs\b") ), + SurfaceField( + "flatfiles.connect_timeout_secs", + _re(r"set_flatfiles_connect_timeout_secs\b"), + ), + SurfaceField( + "flatfiles.read_timeout_secs", + _re(r"set_flatfiles_read_timeout_secs\b"), + ), ] surfaces.append(ffi) @@ -607,6 +617,14 @@ def build_surfaces() -> list[Surface]: SurfaceField( "flatfiles.max_backoff_secs", _re(r"set_flatfiles_max_backoff_secs\b") ), + SurfaceField( + "flatfiles.connect_timeout_secs", + _re(r"set_flatfiles_connect_timeout_secs\b"), + ), + SurfaceField( + "flatfiles.read_timeout_secs", + _re(r"set_flatfiles_read_timeout_secs\b"), + ), ] surfaces.append(cpp_h) @@ -654,6 +672,14 @@ def build_surfaces() -> list[Surface]: SurfaceField( "flatfiles.max_backoff_secs", _re(r"setFlatfilesMaxBackoffSecs\b") ), + SurfaceField( + "flatfiles.connect_timeout_secs", + _re(r"setFlatfilesConnectTimeoutSecs\b"), + ), + SurfaceField( + "flatfiles.read_timeout_secs", + _re(r"setFlatfilesReadTimeoutSecs\b"), + ), ] surfaces.append(ts) @@ -701,6 +727,14 @@ def build_surfaces() -> list[Surface]: SurfaceField( "flatfiles.max_backoff_secs", _re(r"^\s*flatfiles_max_backoff_secs:") ), + SurfaceField( + "flatfiles.connect_timeout_secs", + _re(r"^\s*flatfiles_connect_timeout_secs:"), + ), + SurfaceField( + "flatfiles.read_timeout_secs", + _re(r"^\s*flatfiles_read_timeout_secs:"), + ), SurfaceField("streaming.timeout_ms", _re(r"^\s*streaming_timeout_ms:")), SurfaceField("streaming.data_watchdog_ms", _re(r"^\s*streaming_data_watchdog_ms:")), SurfaceField( @@ -805,6 +839,8 @@ def _selftest() -> int: initial_backoff: Duration::from_secs(1), max_backoff: Duration::from_secs(30), jitter: true, + connect_timeout_secs: 10, + read_timeout_secs: 60, } } } diff --git a/sdks/cpp/include/thetadatadx.h b/sdks/cpp/include/thetadatadx.h index 249f4f2ce..3c41a40e1 100644 --- a/sdks/cpp/include/thetadatadx.h +++ b/sdks/cpp/include/thetadatadx.h @@ -1626,6 +1626,41 @@ void thetadatadx_config_set_flatfiles_max_backoff_secs(ThetaDataDxConfig* config */ int32_t thetadatadx_config_get_flatfiles_max_backoff_secs(const ThetaDataDxConfig* config, uint64_t* out_secs); +/** + * Set the TCP + TLS connect timeout (seconds) for one flatfile-host + * attempt. Bounds the connect/auth handshake before the attempt is + * abandoned and the next host (or the retry ladder) takes over. + * Default 10. + * @param config Config handle to mutate; no-op when NULL. + * @param secs Connect timeout in seconds. + */ +void thetadatadx_config_set_flatfiles_connect_timeout_secs(ThetaDataDxConfig* config, uint64_t secs); + +/** + * Read the flatfile connect-timeout setting (seconds). + * @param config Config handle to read. + * @param out_secs Receives the connect timeout in seconds on success. + * @return 0 on success, -1 if either pointer is null. + */ +int32_t thetadatadx_config_get_flatfiles_connect_timeout_secs(const ThetaDataDxConfig* config, uint64_t* out_secs); + +/** + * Set the read timeout (seconds) for a single flatfile response frame. + * Bounds the wait for the next chunk once streaming has begun so a + * mid-stream stall fails over instead of blocking forever. Default 60. + * @param config Config handle to mutate; no-op when NULL. + * @param secs Read timeout in seconds. + */ +void thetadatadx_config_set_flatfiles_read_timeout_secs(ThetaDataDxConfig* config, uint64_t secs); + +/** + * Read the flatfile read-timeout setting (seconds). + * @param config Config handle to read. + * @param out_secs Receives the read timeout in seconds on success. + * @return 0 on success, -1 if either pointer is null. + */ +int32_t thetadatadx_config_get_flatfiles_read_timeout_secs(const ThetaDataDxConfig* config, uint64_t* out_secs); + /* ── AuthConfig field setters/getters ── */ /** diff --git a/sdks/cpp/include/thetadatadx.hpp b/sdks/cpp/include/thetadatadx.hpp index befc1e3bc..24a83f1de 100644 --- a/sdks/cpp/include/thetadatadx.hpp +++ b/sdks/cpp/include/thetadatadx.hpp @@ -1088,6 +1088,30 @@ class Config { return out; } + /** TCP + TLS connect timeout (seconds) for one flatfile-host attempt. + * Bounds the connect/auth handshake before the next host (or the + * retry ladder) takes over. Default 10. */ + void set_flatfiles_connect_timeout_secs(uint64_t secs) { + thetadatadx_config_set_flatfiles_connect_timeout_secs(handle_.get(), secs); + } + uint64_t get_flatfiles_connect_timeout_secs() const { + uint64_t out{}; + thetadatadx_config_get_flatfiles_connect_timeout_secs(handle_.get(), &out); + return out; + } + + /** Read timeout (seconds) for a single flatfile response frame. Bounds + * the wait for the next chunk so a mid-stream stall fails over + * instead of blocking forever. Default 60. */ + void set_flatfiles_read_timeout_secs(uint64_t secs) { + thetadatadx_config_set_flatfiles_read_timeout_secs(handle_.get(), secs); + } + uint64_t get_flatfiles_read_timeout_secs() const { + uint64_t out{}; + thetadatadx_config_get_flatfiles_read_timeout_secs(handle_.get(), &out); + return out; + } + // ── AuthConfig field setters/getters ── /** diff --git a/sdks/cpp/tests/flatfiles_config.cpp b/sdks/cpp/tests/flatfiles_config.cpp index cb0ce6a01..ac642ee32 100644 --- a/sdks/cpp/tests/flatfiles_config.cpp +++ b/sdks/cpp/tests/flatfiles_config.cpp @@ -2,8 +2,9 @@ // with Python / TypeScript / FFI. // // Pins the C++ surface contract for `set_flatfiles_max_attempts`, -// `set_flatfiles_initial_backoff_secs`, and -// `set_flatfiles_max_backoff_secs`. The Rust core enforces the +// `set_flatfiles_initial_backoff_secs`, +// `set_flatfiles_max_backoff_secs`, `set_flatfiles_connect_timeout_secs`, +// and `set_flatfiles_read_timeout_secs`. The Rust core enforces the // `[1, 10]` range on `max_attempts` and the // `max_backoff >= initial_backoff` invariant at // `DirectConfig::validate` time, not at the C ABI setter; this file @@ -24,6 +25,8 @@ TEST_CASE("Config exposes FlatFilesConfig production defaults", REQUIRE(cfg.get_flatfiles_max_attempts() == 10u); REQUIRE(cfg.get_flatfiles_initial_backoff_secs() == 1u); REQUIRE(cfg.get_flatfiles_max_backoff_secs() == 30u); + REQUIRE(cfg.get_flatfiles_connect_timeout_secs() == 10u); + REQUIRE(cfg.get_flatfiles_read_timeout_secs() == 60u); } TEST_CASE("Config::set_flatfiles_max_attempts round-trips via getter", @@ -58,6 +61,28 @@ TEST_CASE("Config::set_flatfiles_max_backoff_secs round-trips via getter", } } +TEST_CASE("Config::set_flatfiles_connect_timeout_secs round-trips via getter", + "[config][flatfiles][offline]") { + auto cfg = thetadatadx::Config::production(); + for (std::uint64_t secs : {std::uint64_t{0}, std::uint64_t{1}, + std::uint64_t{4}, std::uint64_t{10}, + std::uint64_t{60}, std::uint64_t{3600}}) { + REQUIRE_NOTHROW(cfg.set_flatfiles_connect_timeout_secs(secs)); + REQUIRE(cfg.get_flatfiles_connect_timeout_secs() == secs); + } +} + +TEST_CASE("Config::set_flatfiles_read_timeout_secs round-trips via getter", + "[config][flatfiles][offline]") { + auto cfg = thetadatadx::Config::production(); + for (std::uint64_t secs : {std::uint64_t{0}, std::uint64_t{1}, + std::uint64_t{4}, std::uint64_t{60}, + std::uint64_t{3600}, std::uint64_t{86'400}}) { + REQUIRE_NOTHROW(cfg.set_flatfiles_read_timeout_secs(secs)); + REQUIRE(cfg.get_flatfiles_read_timeout_secs() == secs); + } +} + TEST_CASE("FlatFiles setters compose with pool-sizing setters", "[config][flatfiles][offline]") { // Interleaved flatfiles setter and pool-sizing setter calls on @@ -66,9 +91,13 @@ TEST_CASE("FlatFiles setters compose with pool-sizing setters", REQUIRE_NOTHROW(cfg.set_flatfiles_max_attempts(7)); REQUIRE_NOTHROW(cfg.set_flatfiles_initial_backoff_secs(3)); REQUIRE_NOTHROW(cfg.set_flatfiles_max_backoff_secs(12)); + REQUIRE_NOTHROW(cfg.set_flatfiles_connect_timeout_secs(20)); + REQUIRE_NOTHROW(cfg.set_flatfiles_read_timeout_secs(45)); REQUIRE_NOTHROW(cfg.set_concurrent_requests(4)); REQUIRE(cfg.get_flatfiles_max_attempts() == 7u); REQUIRE(cfg.get_flatfiles_initial_backoff_secs() == 3u); REQUIRE(cfg.get_flatfiles_max_backoff_secs() == 12u); + REQUIRE(cfg.get_flatfiles_connect_timeout_secs() == 20u); + REQUIRE(cfg.get_flatfiles_read_timeout_secs() == 45u); } diff --git a/sdks/parity.toml b/sdks/parity.toml index 61b2cae81..097865f45 100644 --- a/sdks/parity.toml +++ b/sdks/parity.toml @@ -227,7 +227,9 @@ cpp = true # `thetadatadx::Config::set_flatfiles_*` (which forwards to the FFI # `thetadatadx_config_set_flatfiles_*` symbols). `max_attempts` is `u32`, the # two `Duration` fields cross the binding boundary as `u64` seconds -# (`initial_backoff_secs` / `max_backoff_secs`). +# (`initial_backoff_secs` / `max_backoff_secs`), and the two timeout +# fields (`connect_timeout_secs` / `read_timeout_secs`) are `u64` seconds +# directly. [[class]] name = "FlatFilesConfig.max_attempts" @@ -253,6 +255,18 @@ python = true typescript = true cpp = true +[[class]] +name = "FlatFilesConfig.connect_timeout_secs" +python = true +typescript = true +cpp = true + +[[class]] +name = "FlatFilesConfig.read_timeout_secs" +python = true +typescript = true +cpp = true + # ─── HistoricalConfig cross-binding setters ──────────────────────────── # # Tuning knobs on the historical sub-config exposed across every binding. diff --git a/sdks/python/python/thetadatadx/__init__.pyi b/sdks/python/python/thetadatadx/__init__.pyi index b1502d655..ea89bb083 100644 --- a/sdks/python/python/thetadatadx/__init__.pyi +++ b/sdks/python/python/thetadatadx/__init__.pyi @@ -155,6 +155,10 @@ class Config: """Ceiling back-off, in seconds, of the flat-file driver retry loop (default 30).""" flatfiles_jitter: bool """Whether jitter is applied to flat-file driver retry delays (default ``True``).""" + flatfiles_connect_timeout_secs: int + """TCP + TLS connect timeout, in seconds, for one flat-file host attempt (default 10).""" + flatfiles_read_timeout_secs: int + """Read timeout, in seconds, for a single flat-file response frame (default 60).""" nexus_url: str """Authentication endpoint URL (defaults to the production endpoint).""" client_type: str diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index 73558ce5d..80b83b7ca 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -958,6 +958,40 @@ impl Config { guard.flatfiles.max_backoff.as_secs() } + /// Set the TCP + TLS connect timeout (seconds) for one flatfile-host + /// attempt. Bounds the connect/auth handshake before the attempt is + /// abandoned and the next host (or the retry ladder) takes over. + /// Default ``10``. + #[setter] + fn set_flatfiles_connect_timeout_secs(&self, secs: u64) { + let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.flatfiles.connect_timeout_secs = secs; + } + + /// Current ``flatfiles.connect_timeout_secs`` value in seconds. + #[getter] + fn get_flatfiles_connect_timeout_secs(&self) -> u64 { + let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.flatfiles.connect_timeout_secs + } + + /// Set the read timeout (seconds) for a single flatfile response + /// frame. Bounds the wait for the next chunk once streaming has begun + /// so a mid-stream stall fails over instead of blocking forever. + /// Default ``60``. + #[setter] + fn set_flatfiles_read_timeout_secs(&self, secs: u64) { + let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.flatfiles.read_timeout_secs = secs; + } + + /// Current ``flatfiles.read_timeout_secs`` value in seconds. + #[getter] + fn get_flatfiles_read_timeout_secs(&self) -> u64 { + let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.flatfiles.read_timeout_secs + } + // ── AuthConfig field setters/getters ────────────────────────────── // // Per-field access on ``DirectConfig.auth``. Both fields are diff --git a/sdks/python/tests/test_flatfiles_config.py b/sdks/python/tests/test_flatfiles_config.py index 30fd5cba8..aa1b1f67c 100644 --- a/sdks/python/tests/test_flatfiles_config.py +++ b/sdks/python/tests/test_flatfiles_config.py @@ -2,7 +2,8 @@ TypeScript / C++ / FFI. Pins the Python surface contract for ``flatfiles_max_attempts``, -``flatfiles_initial_backoff_secs``, and ``flatfiles_max_backoff_secs``. +``flatfiles_initial_backoff_secs``, ``flatfiles_max_backoff_secs``, +``flatfiles_connect_timeout_secs``, and ``flatfiles_read_timeout_secs``. The Rust core enforces the ``[1, 10]`` range on ``max_attempts`` and the ``max_backoff >= initial_backoff`` invariant at ``DirectConfig::validate`` time; this file pins only that the Python @@ -34,6 +35,8 @@ def test_flatfiles_defaults_mirror_production_defaults() -> None: assert cfg.flatfiles_max_attempts == 10 assert cfg.flatfiles_initial_backoff_secs == 1 assert cfg.flatfiles_max_backoff_secs == 30 + assert cfg.flatfiles_connect_timeout_secs == 10 + assert cfg.flatfiles_read_timeout_secs == 60 # ─── Round-trip ───────────────────────────────────────────────────── @@ -71,6 +74,24 @@ def test_flatfiles_max_backoff_secs_round_trips() -> None: assert cfg.flatfiles_max_backoff_secs == secs +def test_flatfiles_connect_timeout_secs_round_trips() -> None: + """Setter / getter pair round-trips across the documented u64 range.""" + mod = _import_module() + cfg = mod.Config.production() + for secs in (0, 1, 4, 10, 60, 3_600): + cfg.flatfiles_connect_timeout_secs = secs + assert cfg.flatfiles_connect_timeout_secs == secs + + +def test_flatfiles_read_timeout_secs_round_trips() -> None: + """Setter / getter pair round-trips across the documented u64 range.""" + mod = _import_module() + cfg = mod.Config.production() + for secs in (0, 1, 4, 10, 60, 3_600, 86_400): + cfg.flatfiles_read_timeout_secs = secs + assert cfg.flatfiles_read_timeout_secs == secs + + # ─── Boundary cases ───────────────────────────────────────────────── @@ -92,6 +113,10 @@ def test_flatfiles_backoff_secs_rejects_negative() -> None: cfg.flatfiles_initial_backoff_secs = -1 with pytest.raises(OverflowError): cfg.flatfiles_max_backoff_secs = -1 + with pytest.raises(OverflowError): + cfg.flatfiles_connect_timeout_secs = -1 + with pytest.raises(OverflowError): + cfg.flatfiles_read_timeout_secs = -1 def test_flatfiles_backoff_secs_rejects_above_u64() -> None: @@ -102,6 +127,10 @@ def test_flatfiles_backoff_secs_rejects_above_u64() -> None: cfg.flatfiles_initial_backoff_secs = 1 << 65 with pytest.raises(OverflowError): cfg.flatfiles_max_backoff_secs = 1 << 65 + with pytest.raises(OverflowError): + cfg.flatfiles_connect_timeout_secs = 1 << 65 + with pytest.raises(OverflowError): + cfg.flatfiles_read_timeout_secs = 1 << 65 # ─── Composed state ───────────────────────────────────────────────── @@ -117,9 +146,13 @@ def test_flatfiles_field_setters_compose_into_consistent_config() -> None: cfg.flatfiles_max_attempts = 5 cfg.flatfiles_initial_backoff_secs = 2 cfg.flatfiles_max_backoff_secs = 30 + cfg.flatfiles_connect_timeout_secs = 15 + cfg.flatfiles_read_timeout_secs = 90 assert cfg.flatfiles_max_attempts == 5 assert cfg.flatfiles_initial_backoff_secs == 2 assert cfg.flatfiles_max_backoff_secs == 30 + assert cfg.flatfiles_connect_timeout_secs == 15 + assert cfg.flatfiles_read_timeout_secs == 90 def test_flatfiles_setter_state_survives_interleaved_calls() -> None: @@ -131,8 +164,12 @@ def test_flatfiles_setter_state_survives_interleaved_calls() -> None: cfg.flatfiles_max_attempts = 7 cfg.flatfiles_initial_backoff_secs = 3 cfg.flatfiles_max_backoff_secs = 12 + cfg.flatfiles_connect_timeout_secs = 20 + cfg.flatfiles_read_timeout_secs = 45 cfg.concurrent_requests = 4 assert cfg.concurrent_requests == 4 assert cfg.flatfiles_max_attempts == 7 assert cfg.flatfiles_initial_backoff_secs == 3 assert cfg.flatfiles_max_backoff_secs == 12 + assert cfg.flatfiles_connect_timeout_secs == 20 + assert cfg.flatfiles_read_timeout_secs == 45 diff --git a/sdks/typescript/__tests__/flatfiles_config.test.mjs b/sdks/typescript/__tests__/flatfiles_config.test.mjs index c07415b88..7309f164b 100644 --- a/sdks/typescript/__tests__/flatfiles_config.test.mjs +++ b/sdks/typescript/__tests__/flatfiles_config.test.mjs @@ -1,7 +1,8 @@ // FlatFilesConfig setters on `Config` — TypeScript binding parity // with Python / C++ / FFI. Pins the JS surface contract for -// `setFlatfilesMaxAttempts`, `setFlatfilesInitialBackoffSecs`, and -// `setFlatfilesMaxBackoffSecs`. +// `setFlatfilesMaxAttempts`, `setFlatfilesInitialBackoffSecs`, +// `setFlatfilesMaxBackoffSecs`, `setFlatfilesConnectTimeoutSecs`, and +// `setFlatfilesReadTimeoutSecs`. // // The Rust core enforces the `[1, 10]` range on `max_attempts` and // the `max_backoff >= initial_backoff` invariant at @@ -22,11 +23,13 @@ try { const { Config } = mod; describe('Config.flatFiles* — defaults mirror FlatFilesConfig::production_defaults', () => { - it('expose the three FlatFilesConfig field defaults', () => { + it('expose the FlatFilesConfig field defaults', () => { const cfg = Config.production(); assert.equal(cfg.flatfilesMaxAttempts, 10); assert.equal(cfg.flatfilesInitialBackoffSecs, 1n); assert.equal(cfg.flatfilesMaxBackoffSecs, 30n); + assert.equal(cfg.flatfilesConnectTimeoutSecs, 10n); + assert.equal(cfg.flatfilesReadTimeoutSecs, 60n); }); }); @@ -78,16 +81,58 @@ describe('Config.setFlatfilesMaxBackoffSecs', () => { }); }); +describe('Config.setFlatfilesConnectTimeoutSecs', () => { + it('round-trips through the setter across the documented u64 range', () => { + const cfg = Config.production(); + for (const secs of [0n, 1n, 4n, 10n, 60n, 3_600n]) { + cfg.setFlatfilesConnectTimeoutSecs(secs); + assert.equal(cfg.flatfilesConnectTimeoutSecs, secs); + } + }); + + it('rejects BigInt magnitudes above u64::MAX', () => { + const cfg = Config.production(); + assert.throws( + () => cfg.setFlatfilesConnectTimeoutSecs(1n << 65n), + /setFlatfilesConnectTimeoutSecs/, + 'magnitude above u64::MAX must be rejected at the boundary', + ); + }); +}); + +describe('Config.setFlatfilesReadTimeoutSecs', () => { + it('round-trips through the setter across the documented u64 range', () => { + const cfg = Config.production(); + for (const secs of [0n, 1n, 4n, 10n, 60n, 3_600n, 86_400n]) { + cfg.setFlatfilesReadTimeoutSecs(secs); + assert.equal(cfg.flatfilesReadTimeoutSecs, secs); + } + }); + + it('rejects BigInt magnitudes above u64::MAX', () => { + const cfg = Config.production(); + assert.throws( + () => cfg.setFlatfilesReadTimeoutSecs(1n << 65n), + /setFlatfilesReadTimeoutSecs/, + 'magnitude above u64::MAX must be rejected at the boundary', + ); + }); +}); + describe('FlatFiles setter state survives interleaved pool-sizing calls', () => { it('FlatFiles setter mutations land independently of pool-sizing mutations', () => { const cfg = Config.production(); cfg.setFlatfilesMaxAttempts(7); cfg.setFlatfilesInitialBackoffSecs(3n); cfg.setFlatfilesMaxBackoffSecs(12n); + cfg.setFlatfilesConnectTimeoutSecs(20n); + cfg.setFlatfilesReadTimeoutSecs(45n); cfg.setConcurrentRequests(4); assert.equal(cfg.flatfilesMaxAttempts, 7); assert.equal(cfg.flatfilesInitialBackoffSecs, 3n); assert.equal(cfg.flatfilesMaxBackoffSecs, 12n); + assert.equal(cfg.flatfilesConnectTimeoutSecs, 20n); + assert.equal(cfg.flatfilesReadTimeoutSecs, 45n); assert.equal(cfg.concurrentRequests, 4); }); }); diff --git a/sdks/typescript/index.d.ts b/sdks/typescript/index.d.ts index a46ae8ad3..8b87c1378 100644 --- a/sdks/typescript/index.d.ts +++ b/sdks/typescript/index.d.ts @@ -421,6 +421,30 @@ export declare class Config { setFlatfilesMaxBackoffSecs(secs: bigint): void /** Current `flatfiles.max_backoff` value (seconds, returned as BigInt). */ get flatfilesMaxBackoffSecs(): bigint + /** + * Set the TCP + TLS connect timeout (seconds) for one flatfile-host + * attempt. Bounds the connect/auth handshake before the attempt is + * abandoned and the next host (or the retry ladder) takes over. + * Default `10n`. + * + * Accepts a `bigint` for parity with the other bindings, which + * use a 64-bit unsigned integer. + */ + setFlatfilesConnectTimeoutSecs(secs: bigint): void + /** Current `flatfiles.connect_timeout_secs` value (seconds, returned as BigInt). */ + get flatfilesConnectTimeoutSecs(): bigint + /** + * Set the read timeout (seconds) for a single flatfile response + * frame. Bounds the wait for the next chunk once streaming has begun + * so a mid-stream stall fails over instead of blocking forever. + * Default `60n`. + * + * Accepts a `bigint` for parity with the other bindings, which + * use a 64-bit unsigned integer. + */ + setFlatfilesReadTimeoutSecs(secs: bigint): void + /** Current `flatfiles.read_timeout_secs` value (seconds, returned as BigInt). */ + get flatfilesReadTimeoutSecs(): bigint /** * Set the Nexus auth URL. Default matches the upstream * production endpoint; override to redirect at a staging diff --git a/sdks/typescript/src/config_class.rs b/sdks/typescript/src/config_class.rs index c3d71648e..f89c85e3b 100644 --- a/sdks/typescript/src/config_class.rs +++ b/sdks/typescript/src/config_class.rs @@ -1282,6 +1282,82 @@ impl Config { )) } + /// Set the TCP + TLS connect timeout (seconds) for one flatfile-host + /// attempt. Bounds the connect/auth handshake before the attempt is + /// abandoned and the next host (or the retry ladder) takes over. + /// Default `10n`. + /// + /// Accepts a `bigint` for parity with the other bindings, which + /// use a 64-bit unsigned integer. + #[napi(js_name = "setFlatfilesConnectTimeoutSecs")] + pub fn set_flatfiles_connect_timeout_secs( + &self, + secs: napi::bindgen_prelude::BigInt, + ) -> napi::Result<()> { + let (_signed, value, lossless) = secs.get_u64(); + if !lossless { + return Err(napi::Error::from_reason( + "setFlatfilesConnectTimeoutSecs: BigInt magnitude must fit in u64", + )); + } + let mut guard = self + .inner + .lock() + .map_err(|_| napi::Error::from_reason("Config mutex poisoned"))?; + guard.flatfiles.connect_timeout_secs = value; + Ok(()) + } + + /// Current `flatfiles.connect_timeout_secs` value (seconds, returned as BigInt). + #[napi(getter, js_name = "flatfilesConnectTimeoutSecs")] + pub fn flatfiles_connect_timeout_secs(&self) -> napi::Result { + let guard = self + .inner + .lock() + .map_err(|_| napi::Error::from_reason("Config mutex poisoned"))?; + Ok(napi::bindgen_prelude::BigInt::from( + guard.flatfiles.connect_timeout_secs, + )) + } + + /// Set the read timeout (seconds) for a single flatfile response + /// frame. Bounds the wait for the next chunk once streaming has begun + /// so a mid-stream stall fails over instead of blocking forever. + /// Default `60n`. + /// + /// Accepts a `bigint` for parity with the other bindings, which + /// use a 64-bit unsigned integer. + #[napi(js_name = "setFlatfilesReadTimeoutSecs")] + pub fn set_flatfiles_read_timeout_secs( + &self, + secs: napi::bindgen_prelude::BigInt, + ) -> napi::Result<()> { + let (_signed, value, lossless) = secs.get_u64(); + if !lossless { + return Err(napi::Error::from_reason( + "setFlatfilesReadTimeoutSecs: BigInt magnitude must fit in u64", + )); + } + let mut guard = self + .inner + .lock() + .map_err(|_| napi::Error::from_reason("Config mutex poisoned"))?; + guard.flatfiles.read_timeout_secs = value; + Ok(()) + } + + /// Current `flatfiles.read_timeout_secs` value (seconds, returned as BigInt). + #[napi(getter, js_name = "flatfilesReadTimeoutSecs")] + pub fn flatfiles_read_timeout_secs(&self) -> napi::Result { + let guard = self + .inner + .lock() + .map_err(|_| napi::Error::from_reason("Config mutex poisoned"))?; + Ok(napi::bindgen_prelude::BigInt::from( + guard.flatfiles.read_timeout_secs, + )) + } + // ── AuthConfig field setters/getters ────────────────────────── /// Set the Nexus auth URL. Default matches the upstream