Skip to main content

rustrade_data/exchange/ibkr/flex/
mod.rs

1//! IBKR **Flex Web Service** corporate-action reconciliation.
2//!
3//! Fetches an account's *Activity* Flex statement over HTTPS and exposes its Corporate Actions
4//! section as faithful [`IbkrFlexCorporateAction`] records. This is a **reconciliation / audit**
5//! surface — broker-confirmed, account-scoped, and post-hoc — *not* a market-reference split source.
6//! It does **not** derive split ratios; a caller that wants a ratio cross-references a dedicated
7//! split source (and owns reconcile policy). See [`corporate_action`] for the record contract.
8//!
9//! # Transport (2-call Flex Web Service flow)
10//!
11//! The Flex Web Service generates statements asynchronously, so a fetch is two calls:
12//!
13//! 1. **SendRequest** — `GET {base}/SendRequest?t={token}&q={queryId}&v=3` returns a
14//!    `<FlexStatementResponse>` with a `ReferenceCode` and a `Url` to poll (or a `Fail` status).
15//! 2. **GetStatement** — `GET {Url}?t={token}&q={referenceCode}&v=3`, polled with a bounded number
16//!    of attempts while the service replies `Warn`/`ErrorCode 1019` ("generation in progress"),
17//!    until it returns the `<FlexQueryResponse>` statement (or a terminal error).
18//!
19//! Non-success statuses (e.g. `1003` invalid token, `1018` throttled, token-expired) surface as
20//! [`IbkrFlexError::Flex`]; a non-success HTTP status whose body is *not* a recognizable Flex
21//! envelope (e.g. a proxy/CDN error page) surfaces as [`IbkrFlexError::HttpStatus`]; exhausting the
22//! poll budget surfaces as [`IbkrFlexError::PollTimeout`].
23//!
24//! This service uses an HTTPS token + saved-query id — it does **not** use IB Gateway / TWS, so it
25//! is entirely independent of the socket [`IbkrStreamConfig`](crate::exchange::ibkr::IbkrStreamConfig).
26//!
27//! # Known limitations
28//!
29//! - **Post-hoc (T+1+).** A Flex statement reports actions *after* the broker books them; the
30//!   records are for reconciliation/audit, not for injecting split events into a live/backtest
31//!   timeline at the market execution instant.
32//! - **Account-scoped.** Quantities are this account's deltas, not market-wide figures.
33//! - **No library-derived ratio.** Flex carries no standardised split-ratio field; the wrapper
34//!   derives/verifies ratios from a market-reference source.
35//! - **Query-configuration-dependent date format.** The raw `dateTime` format depends on the saved
36//!   query's settings (see [`IbkrFlexCorporateAction::date_time`]).
37//! - **The saved Flex query must include the Corporate Actions section** over an account-activity
38//!   scope, otherwise the statement contains no `<CorporateAction>` rows to return.
39//!
40//! # Credentials
41//!
42//! Construct from a [`IbkrFlexConfig`] (an Activity-statement Flex `token` + saved-query `query_id`).
43//! [`IbkrFlexClient::from_env`] reads `IBKR_FLEX_TOKEN` and `IBKR_FLEX_QUERY_ID`.
44
45mod corporate_action;
46
47pub use corporate_action::{IbkrFlexCorporateAction, IbkrReorgType, parse_corporate_actions};
48
49use crate::exchange::http::{MAX_ERROR_BODY_DOWNLOAD_BYTES, read_body_capped, truncate_str};
50use quick_xml::{Reader, events::Event};
51use serde::Deserialize;
52use smol_str::SmolStr;
53use std::{env, fmt, time::Duration};
54use thiserror::Error;
55use tracing::debug;
56
57/// Base URL of the IBKR Flex Web Service (`SendRequest` lives directly under it; the poll URL is
58/// taken from the SendRequest response, not hard-coded).
59const FLEX_BASE_URL: &str =
60    "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService";
61
62/// Flex Web Service protocol version (`v` query parameter).
63const FLEX_VERSION: &str = "3";
64
65/// Timeout for each individual HTTP request.
66const REQUEST_TIMEOUT_SECS: u64 = 30;
67
68/// Maximum number of GetStatement poll attempts before giving up.
69const POLL_MAX_ATTEMPTS: u32 = 12;
70
71/// Delay between GetStatement poll attempts while the statement is still generating.
72const POLL_INTERVAL: Duration = Duration::from_secs(5);
73
74/// Delay before the *first* GetStatement poll. The statement is generated asynchronously, so the
75/// first poll almost always reports [`GENERATION_IN_PROGRESS_CODE`]; waiting once up front keeps
76/// that near-certain miss from consuming one of the bounded poll attempts. Overridable via
77/// [`FlexPollPolicy`].
78const POLL_INITIAL_DELAY: Duration = Duration::from_secs(5);
79
80/// Flex `ErrorCode` meaning "statement generation in progress, try again shortly" — the only
81/// non-success status that should be retried rather than surfaced as an error.
82const GENERATION_IN_PROGRESS_CODE: &str = "1019";
83
84/// Maximum number of bytes of a non-Flex error body retained in [`IbkrFlexError::HttpStatus`]. Flex
85/// and IBKR error envelopes are tiny; this only bounds an unexpectedly large proxy/CDN error page.
86const MAX_ERROR_BODY_BYTES: usize = 1024;
87
88// The redact-before-bound ordering in `sanitize_error_body` is only safe while the *gap* between the
89// download and retained caps exceeds the token length. A token straddling the coarse
90// `MAX_ERROR_BODY_DOWNLOAD_BYTES` pre-bound survives redaction as an unmatched prefix fragment pinned
91// to the tail of the pre-bounded slice: it ends at the download cap, so a fragment of length `f`
92// starts at `MAX_ERROR_BODY_DOWNLOAD_BYTES - f`. The final `MAX_ERROR_BODY_BYTES` truncation discards
93// it when that start lands at or past the retained cap — i.e. when `f` is at most the gap
94// `MAX_ERROR_BODY_DOWNLOAD_BYTES - MAX_ERROR_BODY_BYTES`. A straddling fragment is always shorter than
95// the whole token, so once the gap exceeds the longest possible token every such fragment is
96// truncated away. That is the invariant.
97//
98// The assert below is a proportional proxy for that gap, not a proof for arbitrary token lengths: a
99// 4x ratio keeps the gap at a floor of 3 * MAX_ERROR_BODY_BYTES (3 KiB at the current retained cap;
100// the actual current gap is far larger), which dwarfs any real Flex token (a short opaque string).
101// Its job is to fail the build — the two constants live in different modules — if a future edit
102// collapses the caps close enough together to shrink that margin below a plausible token, silently
103// reopening the straddling leak.
104const _: () = assert!(
105    MAX_ERROR_BODY_DOWNLOAD_BYTES >= MAX_ERROR_BODY_BYTES * 4,
106    "MAX_ERROR_BODY_DOWNLOAD_BYTES must stay well above MAX_ERROR_BODY_BYTES so a token straddling \
107     the download-cap boundary is truncated away by the retained-cap bound (see sanitize_error_body)"
108);
109
110/// Errors from IBKR Flex Web Service operations.
111///
112/// `#[non_exhaustive]`: the Flex service may introduce new failure conditions over time, so new
113/// variants can be added without a breaking change; downstream `match`es must include a wildcard arm.
114#[non_exhaustive]
115#[derive(Debug, Error)]
116pub enum IbkrFlexError {
117    /// A required environment variable is not set (see [`IbkrFlexConfig::from_env`]).
118    #[error("environment variable error: {0}")]
119    EnvVar(String),
120
121    /// A supplied credential (the Flex `token` or `query_id`) is empty after trimming surrounding
122    /// whitespace. Raised by [`IbkrFlexConfig::new`] so a malformed credential fails observably at
123    /// construction, naming the offending field, rather than later as an opaque IBKR `1003`
124    /// "invalid token" after a live fetch.
125    #[error("invalid credential: {0}")]
126    InvalidCredential(String),
127
128    /// Transport-level HTTP error (connection, timeout, or body-decode failure).
129    ///
130    /// A non-2xx HTTP *status* no longer surfaces here: the client reads the response body **before**
131    /// branching on status and reports a non-success status as [`IbkrFlexError::HttpStatus`] (or the
132    /// richer [`IbkrFlexError::Flex`] when the body is a Flex error envelope). This variant is now
133    /// purely a connection/timeout/decode failure.
134    ///
135    /// The inner [`reqwest::Error`] **never carries the request URL.** The Flex token is
136    /// transmitted as `t=<TOKEN>` in every request URL, and `reqwest::Error`'s `Display` and
137    /// `Debug` both embed that URL verbatim when present. The `From<reqwest::Error>` conversion
138    /// for this type always strips it via [`reqwest::Error::without_url`] before the error is
139    /// stored, covering every reqwest site that attaches a URL (`send`, body decode);
140    /// `Client::build()` errors carry no URL, so the strip is a no-op for them. The `source()`
141    /// chain (hyper/IO transport errors) is preserved and does not independently carry the URL.
142    ///
143    /// Safe to log or display without credential scrubbing.
144    #[error("HTTP error: {0}")]
145    Http(reqwest::Error),
146
147    /// The service returned a non-success HTTP status whose body was **not** a recognizable Flex
148    /// envelope — e.g. a proxy/CDN/WAF error page or a gateway timeout served in front of the Flex
149    /// Web Service.
150    ///
151    /// The body is read before the status is inspected (so the diagnostic is preserved rather than
152    /// discarded by `error_for_status`), then bounded to 1 KiB and
153    /// **token-scrubbed** before storage: the Flex token rides in the request URL's `t=` parameter,
154    /// so a proxy that echoes the request line into its error page could otherwise reflect the
155    /// credential into this body. The scrub is best-effort defense-in-depth — it redacts both the
156    /// raw token and the encoded form it takes on the wire. An IBKR
157    /// *application* error that arrives under a non-2xx status is still surfaced as the richer
158    /// [`IbkrFlexError::Flex`], not here.
159    #[error("HTTP status {status}: {body}")]
160    HttpStatus {
161        /// The non-success HTTP status code.
162        status: u16,
163        /// A bounded, token-scrubbed slice of the response body.
164        body: String,
165    },
166
167    /// The Flex service returned a non-success status (e.g. invalid token, throttled, expired).
168    ///
169    /// `message` is the `<ErrorMessage>` element lifted from a server-supplied body, so — like
170    /// [`IbkrFlexError::HttpStatus`] and [`IbkrFlexError::Parse`] — both fields are bounded to 1 KiB
171    /// and **token-scrubbed** before storage: a proxy that reflected the request line into its
172    /// `<ErrorMessage>` could otherwise carry the `t=` Flex token into it. The scrub is a no-op for a
173    /// real numeric Flex code/message that does not contain the token.
174    #[error("Flex service error ({code}): {message}")]
175    Flex {
176        /// The Flex `ErrorCode` (empty if the response carried none), bounded and token-scrubbed
177        /// like `message`.
178        code: String,
179        /// The Flex `ErrorMessage` (or a synthesised description), bounded and token-scrubbed.
180        message: String,
181    },
182
183    /// The statement was still generating after the configured poll budget was exhausted.
184    #[error("Flex statement not ready after {attempts} poll attempts")]
185    PollTimeout {
186        /// Number of poll attempts made before giving up — the configured
187        /// [`FlexPollPolicy::max_attempts`] (12 by default), since this error
188        /// fires only after every attempt reported the statement still generating.
189        attempts: u32,
190    },
191
192    /// The statement or status XML could not be parsed.
193    ///
194    /// A parse message embeds the deserialiser's own rendering of the offending input, so its
195    /// length tracks the *document* rather than the failure, and a document that reflected the
196    /// request line could carry the `t=` credential into it. This variant is therefore **always
197    /// bounded** to 1 KiB, and **token-scrubbed** on every path where a token is in scope — the
198    /// same protection [`IbkrFlexError::HttpStatus`] gets, for the same reason.
199    ///
200    /// Scrubbing covers every `Parse` returned by [`IbkrFlexClient::fetch_statement_xml`] and
201    /// [`IbkrFlexClient::fetch_corporate_actions`]. The one path it cannot cover is calling
202    /// [`parse_corporate_actions`] directly: there the caller supplies its own XML and no token
203    /// exists to redact. Such a message is still bounded — just not scrubbed, since there is
204    /// nothing to scrub against.
205    #[error("Flex XML parse error: {0}")]
206    Parse(String),
207}
208
209/// Convert a [`reqwest::Error`] into [`IbkrFlexError::Http`] **with its request URL stripped**.
210///
211/// The Flex `token` rides in the `t=` query parameter (IBKR's protocol), so it is part of every
212/// request URL. `reqwest::Error`'s `Display`/`Debug` embed the stored URL verbatim — e.g.
213/// `"... for url (https://.../SendRequest?t=<TOKEN>&q=...)"` — so an unstripped error would leak the
214/// credential into any log that formats [`IbkrFlexError::Http`] (the shipped example does exactly
215/// that).
216///
217/// Stripping lives in this single conversion rather than per-call-site discipline so the
218/// "[`IbkrFlexError::Http`] never carries the URL" invariant is enforced by the type system: every
219/// `?` on a `reqwest::Error` — at the request sites (`send`, body decode) *and* any path added later
220/// — routes through here.
221/// [`reqwest::Error::without_url`] sets the stored URL to `None` (a no-op for `Client::build()`
222/// errors, which carry none) while preserving the diagnostic kind/status and `source()` chain.
223impl From<reqwest::Error> for IbkrFlexError {
224    fn from(error: reqwest::Error) -> Self {
225        IbkrFlexError::Http(error.without_url())
226    }
227}
228
229/// Poll timing for the GetStatement stage of [`IbkrFlexClient::fetch_statement_xml`].
230///
231/// The Flex Web Service generates statements asynchronously, so a fetch polls GetStatement until the
232/// statement is ready. This policy controls that polling:
233///
234/// - `initial_delay` — waited **once** before the first poll. The statement is created
235///   asynchronously, so the first GetStatement almost always reports `1019` ("still generating"); an
236///   initial delay keeps that near-certain miss from consuming one of the bounded attempts.
237/// - `interval` — waited between subsequent polls.
238/// - `max_attempts` — the number of GetStatement polls before giving up with
239///   [`IbkrFlexError::PollTimeout`].
240///
241/// The [`Default`] budget (`initial_delay` 5 s, `interval` 5 s, `max_attempts` 12) suits a typical
242/// small activity statement. A caller fetching a large statement — which takes longer to generate —
243/// can widen the budget via [`IbkrFlexClient::with_poll_policy`]. A `max_attempts` of `0` yields an
244/// immediate [`IbkrFlexError::PollTimeout`] (an observable "no polling requested" outcome).
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct FlexPollPolicy {
247    /// Delay before the first GetStatement poll.
248    pub initial_delay: Duration,
249    /// Delay between subsequent GetStatement polls.
250    pub interval: Duration,
251    /// Maximum number of GetStatement poll attempts before [`IbkrFlexError::PollTimeout`].
252    pub max_attempts: u32,
253}
254
255impl Default for FlexPollPolicy {
256    fn default() -> Self {
257        Self {
258            initial_delay: POLL_INITIAL_DELAY,
259            interval: POLL_INTERVAL,
260            max_attempts: POLL_MAX_ATTEMPTS,
261        }
262    }
263}
264
265/// Credentials for the IBKR Flex Web Service: an Activity-statement Flex `token` and the id of a
266/// saved Flex query that includes the Corporate Actions section.
267///
268/// `Debug` redacts the token so it never leaks through tracing or panic output.
269#[derive(Clone)]
270pub struct IbkrFlexConfig {
271    token: String,
272    query_id: String,
273}
274
275impl fmt::Debug for IbkrFlexConfig {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        f.debug_struct("IbkrFlexConfig")
278            .field("token", &"[REDACTED]")
279            .field("query_id", &self.query_id)
280            .finish()
281    }
282}
283
284impl IbkrFlexConfig {
285    /// Create a config from an explicit Flex token and saved-query id.
286    ///
287    /// Both values are **trimmed** of surrounding whitespace and rejected if empty (or empty after
288    /// trimming), so a malformed credential fails here — observably, naming the offending field —
289    /// rather than later as an opaque IBKR `1003` "invalid token" at fetch time. This mirrors the
290    /// validation [`from_env`](Self::from_env) performs, and matches the fallible-`new` shape used
291    /// by the other exchange clients in this crate.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`IbkrFlexError::InvalidCredential`] if either value is empty after trimming.
296    pub fn new(
297        token: impl Into<String>,
298        query_id: impl Into<String>,
299    ) -> Result<Self, IbkrFlexError> {
300        Ok(Self {
301            token: Self::require_nonempty_trimmed("token", token.into())?,
302            query_id: Self::require_nonempty_trimmed("query_id", query_id.into())?,
303        })
304    }
305
306    /// Create a config from environment variables.
307    ///
308    /// Reads `IBKR_FLEX_TOKEN` (the Flex Web Service token) and `IBKR_FLEX_QUERY_ID` (the saved
309    /// query id), both required.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`IbkrFlexError::EnvVar`] if either variable is missing or set but empty.
314    pub fn from_env() -> Result<Self, IbkrFlexError> {
315        let token = env::var("IBKR_FLEX_TOKEN")
316            .map_err(|e| IbkrFlexError::EnvVar(format!("IBKR_FLEX_TOKEN: {e}")))?;
317        let query_id = env::var("IBKR_FLEX_QUERY_ID")
318            .map_err(|e| IbkrFlexError::EnvVar(format!("IBKR_FLEX_QUERY_ID: {e}")))?;
319        // Trim before storing so surrounding whitespace can't silently end up in the `t=`/`q=`
320        // query parameters (which IBKR would reject with a confusing 1003 "invalid token" at fetch
321        // time rather than a clear configuration error here).
322        let token = token.trim();
323        let query_id = query_id.trim();
324        if token.is_empty() {
325            return Err(IbkrFlexError::EnvVar(
326                "IBKR_FLEX_TOKEN is set but empty".to_owned(),
327            ));
328        }
329        if query_id.is_empty() {
330            return Err(IbkrFlexError::EnvVar(
331                "IBKR_FLEX_QUERY_ID is set but empty".to_owned(),
332            ));
333        }
334        // Route through `new` so there is a single construction/validation path. The env-var checks
335        // above already produce variable-specific messages (`IBKR_FLEX_TOKEN is set but empty`),
336        // which are more actionable than `new`'s generic field-level error, so they run first; the
337        // re-trim/re-check inside `new` is then a cheap no-op.
338        Self::new(token, query_id)
339    }
340
341    /// Trim `value` and reject it if empty (or empty after trimming), tagging the error with
342    /// `field` so the failure names which credential was malformed. Allocates a new `String` only
343    /// when trimming actually removed characters.
344    fn require_nonempty_trimmed(
345        field: &'static str,
346        value: String,
347    ) -> Result<String, IbkrFlexError> {
348        let trimmed = value.trim();
349        if trimmed.is_empty() {
350            return Err(IbkrFlexError::InvalidCredential(format!(
351                "{field} is empty after trimming whitespace"
352            )));
353        }
354        if trimmed.len() == value.len() {
355            Ok(value)
356        } else {
357            Ok(trimmed.to_owned())
358        }
359    }
360}
361
362/// Client for fetching corporate-action records from the IBKR Flex Web Service.
363///
364/// `Debug` omits both the token (a credential) and the [`reqwest::Client`], so neither can leak.
365#[derive(Clone)]
366pub struct IbkrFlexClient {
367    http: reqwest::Client,
368    token: String,
369    query_id: String,
370    base_url: String,
371    poll_policy: FlexPollPolicy,
372}
373
374impl fmt::Debug for IbkrFlexClient {
375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376        // Deliberately omits the token (a credential) and the `reqwest::Client`.
377        f.debug_struct("IbkrFlexClient")
378            .field("query_id", &self.query_id)
379            .field("base_url", &self.base_url)
380            .field("poll_policy", &self.poll_policy)
381            .finish_non_exhaustive()
382    }
383}
384
385impl IbkrFlexClient {
386    /// Create a client from a [`IbkrFlexConfig`].
387    ///
388    /// # Errors
389    ///
390    /// Returns [`IbkrFlexError::Http`] if the underlying HTTP client cannot be built.
391    pub fn new(config: IbkrFlexConfig) -> Result<Self, IbkrFlexError> {
392        let http = reqwest::Client::builder()
393            .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
394            // Transport-layer token protection. The Flex `token` rides in every request URL's `t=`
395            // query parameter, so it must never traverse an unencrypted connection. Two guards work
396            // together: `redirect::Policy::none()` stops reqwest auto-following any redirect, so a 3xx
397            // can never bounce the token to another (possibly `http://`) URL behind our back; and
398            // `https_only(true)` rejects any request whose own URL is not `https`, catching a
399            // misconfigured `http://` base URL before the token reaches the wire. This client issues
400            // two GETs to known HTTPS endpoints and expects no redirects, so an unexpected 3xx is
401            // returned unfollowed and surfaces downstream as an `IbkrFlexError::HttpStatus` (a
402            // non-success status with a non-Flex body) — never as a silent scheme downgrade. (The
403            // content-layer scheme check on the SendRequest poll URL cannot
404            // intercept redirects reqwest would otherwise follow, which is why these guards exist.)
405            .https_only(true)
406            .redirect(reqwest::redirect::Policy::none())
407            .build()?;
408        Ok(Self {
409            http,
410            token: config.token,
411            query_id: config.query_id,
412            base_url: FLEX_BASE_URL.to_owned(),
413            poll_policy: FlexPollPolicy::default(),
414        })
415    }
416
417    /// Override the GetStatement [`FlexPollPolicy`] (poll timing / budget).
418    ///
419    /// Builder-style; defaults to [`FlexPollPolicy::default`]. Widen the budget for a large
420    /// statement (which takes longer to generate):
421    ///
422    /// ```no_run
423    /// # use std::time::Duration;
424    /// # use rustrade_data::exchange::ibkr::{IbkrFlexClient, FlexPollPolicy};
425    /// # fn f() -> Result<(), Box<dyn std::error::Error>> {
426    /// let _client = IbkrFlexClient::from_env()?.with_poll_policy(FlexPollPolicy {
427    ///     initial_delay: Duration::from_secs(10),
428    ///     interval: Duration::from_secs(10),
429    ///     max_attempts: 30,
430    /// });
431    /// # Ok(())
432    /// # }
433    /// ```
434    #[must_use]
435    pub fn with_poll_policy(mut self, policy: FlexPollPolicy) -> Self {
436        self.poll_policy = policy;
437        self
438    }
439
440    /// Create a client from environment variables (see [`IbkrFlexConfig::from_env`]).
441    ///
442    /// # Errors
443    ///
444    /// Returns [`IbkrFlexError::EnvVar`] if a required variable is missing, or
445    /// [`IbkrFlexError::Http`] if the HTTP client cannot be built.
446    pub fn from_env() -> Result<Self, IbkrFlexError> {
447        Self::new(IbkrFlexConfig::from_env()?)
448    }
449
450    /// Run the full 2-call Flex flow and return the raw `<FlexQueryResponse>` statement XML.
451    ///
452    /// Issues SendRequest, then polls GetStatement until the statement is ready, governed by this
453    /// client's [`FlexPollPolicy`] (an `initial_delay` before the first poll, `interval` between
454    /// subsequent polls, and a `max_attempts` budget). Defaults to [`FlexPollPolicy::default`];
455    /// override with [`with_poll_policy`](Self::with_poll_policy).
456    ///
457    /// # Security
458    ///
459    /// The Flex `token` is transmitted as the `t=` URL **query parameter** (as IBKR's protocol
460    /// requires), so it is part of every request URL. Do not attach request-logging middleware to
461    /// the underlying [`reqwest::Client`] that records full URLs, or the token will be captured in
462    /// plaintext. The poll URL returned by SendRequest is required to be `https://` for the same
463    /// reason — the token must never traverse an unencrypted connection.
464    ///
465    /// For the same reason, [`IbkrFlexError::Http`] must never embed the request URL: a
466    /// `reqwest::Error`'s `Display` includes the URL it was building, which would carry `t=<TOKEN>`
467    /// into any log that formats the error. The `From<reqwest::Error>` conversion for
468    /// [`IbkrFlexError`] strips the URL via [`reqwest::Error::without_url`] before the error is
469    /// stored, so a plain `?` is safe here and in any request path added later.
470    ///
471    /// Two IBKR hosts are involved: `ndcdyn.interactivebrokers.com` (the hard-coded `FLEX_BASE_URL`
472    /// that serves `SendRequest`) and `gdcdyn.interactivebrokers.com` (the statement-download host
473    /// that `SendRequest` returns in its poll URL). The poll host is deliberately **not** pinned to
474    /// an allowlist — only its scheme is enforced (`https://`) — so IBKR can relocate statement
475    /// serving without breaking this client, while the `https://` gate keeps the token encrypted
476    /// regardless of which host is returned.
477    ///
478    /// # Errors
479    ///
480    /// - [`IbkrFlexError::Http`] on a transport failure.
481    /// - [`IbkrFlexError::Flex`] if the service reports a terminal non-success status.
482    /// - [`IbkrFlexError::HttpStatus`] if a non-success HTTP status carries a body that is not a
483    ///   recognizable Flex envelope (e.g. a proxy/CDN error page).
484    /// - [`IbkrFlexError::PollTimeout`] if the statement is still generating after the poll budget.
485    /// - [`IbkrFlexError::Parse`] if a response cannot be parsed.
486    pub async fn fetch_statement_xml(&self) -> Result<String, IbkrFlexError> {
487        let send_url = format!("{}/SendRequest", self.base_url);
488        debug!("Requesting IBKR Flex statement generation");
489
490        let (status, body) = self
491            .get_with_query(&send_url, self.query_id.as_str())
492            .await?;
493        let SendRequestOk {
494            reference_code,
495            url,
496        } = interpret_send_response(status, &body, &self.token)?;
497
498        poll_until_ready(&self.poll_policy, &self.token, async || {
499            self.get_with_query(&url, reference_code.as_str()).await
500        })
501        .await
502    }
503
504    /// Issue a GET to `url` with the standard Flex query parameters (`t`/`q`/`v`) and read the
505    /// response body **regardless of HTTP status**, returning the status alongside the body.
506    ///
507    /// Reading the body before branching on status is deliberate: `error_for_status` discards the
508    /// body, but IBKR/proxy diagnostic bodies — and Flex application errors (e.g. `1019`) that arrive
509    /// under a non-2xx status — are exactly what a caller needs to diagnose a failure. The status is
510    /// returned so the caller ([`interpret_send_response`] / [`interpret_poll_response`]) can decide
511    /// whether an unrecognized body under a non-success status is a transport error
512    /// ([`IbkrFlexError::HttpStatus`]) versus a genuine Flex-envelope error.
513    ///
514    /// A **success** body is the statement payload (a `<FlexQueryResponse>`, which can be large) and
515    /// is read in full. A **non-success** body is only a small Flex status/error envelope or a
516    /// proxy/CDN diagnostic page — never the statement — so it is read only up to
517    /// `MAX_ERROR_BODY_DOWNLOAD_BYTES`, bounding a pathological error page that would otherwise be
518    /// buffered without limit (it is truncated further for storage anyway). The cap is far above any
519    /// real Flex envelope, so a `1019`/error response the poll loop still parses is never truncated.
520    ///
521    /// The `?` on `send`/`chunk`/`text` still routes any `reqwest::Error` through the URL-stripping
522    /// `From<reqwest::Error>` impl, so the token never leaks into an error.
523    async fn get_with_query(
524        &self,
525        url: &str,
526        q: &str,
527    ) -> Result<(reqwest::StatusCode, String), IbkrFlexError> {
528        let response = self
529            .http
530            .get(url)
531            .query(&[("t", self.token.as_str()), ("q", q), ("v", FLEX_VERSION)])
532            .send()
533            .await?;
534        read_status_and_body(response).await
535    }
536
537    /// Fetch the statement and parse its Corporate Actions section into faithful records.
538    ///
539    /// Convenience over [`fetch_statement_xml`](Self::fetch_statement_xml) +
540    /// [`parse_corporate_actions`]. Returns **all** reorg rows; filtering to splits is the caller's
541    /// job. See [`IbkrFlexCorporateAction`] for the record contract and limitations.
542    ///
543    /// # Errors
544    ///
545    /// Same as [`fetch_statement_xml`](Self::fetch_statement_xml).
546    pub async fn fetch_corporate_actions(
547        &self,
548    ) -> Result<Vec<IbkrFlexCorporateAction>, IbkrFlexError> {
549        let xml = self.fetch_statement_xml().await?;
550        // The token is threaded *into* the parse rather than scrubbed off its result: redaction
551        // matches the full token, so it must run before the message is bounded, and
552        // `parse_corporate_actions` bounds before returning. Scrubbing afterwards would leave a
553        // credential straddling the cap present only as an unmatched prefix fragment. This re-parses
554        // a body that already crossed the network, and a misconfigured proxy that echoed the request
555        // line into it would have put `t=<TOKEN>` in reach of a deserialiser message.
556        corporate_action::parse_corporate_actions_scrubbed(&xml, Some(&self.token))
557    }
558}
559
560/// Read a response's HTTP status and body, bounding the read on the error path.
561///
562/// Split out of [`IbkrFlexClient::get_with_query`] so this contract can be exercised against a
563/// synthetic [`reqwest::Response`] with no live socket (reqwest exposes
564/// `From<http::Response<_>>`), which the caller's `send()` cannot be. That matters because the
565/// ordering here is the load-bearing part: the body is read **before** the status is judged, so a
566/// Flex envelope arriving under a non-2xx status — notably the retryable `1019` "still generating"
567/// — survives to be classified. `error_for_status()?.text().await?` would discard it and abort the
568/// poll, and every test that drives [`interpret_poll_response`] with a synthetic `(status, body)`
569/// tuple would still pass. See `read_status_and_body_*` in this module's tests.
570///
571/// A **success** body is the statement payload (a `<FlexQueryResponse>`, which can be large) and is
572/// read in full. A **non-success** body is only a small Flex status/error envelope or a proxy/CDN
573/// diagnostic page — never the statement — so it is capped at [`MAX_ERROR_BODY_DOWNLOAD_BYTES`],
574/// far above any real Flex envelope.
575async fn read_status_and_body(
576    response: reqwest::Response,
577) -> Result<(reqwest::StatusCode, String), IbkrFlexError> {
578    let status = response.status();
579    let body = if status.is_success() {
580        response.text().await?
581    } else {
582        read_body_capped(response, MAX_ERROR_BODY_DOWNLOAD_BYTES).await?
583    };
584    Ok((status, body))
585}
586
587/// Poll GetStatement until the statement is ready or `policy`'s attempt budget is exhausted,
588/// delegating each raw fetch to `poll_once`.
589///
590/// Isolating the loop from the transport keeps its orchestration deterministically testable without
591/// a network round trip: `poll_once` is any async producer of a `(status, body)` pair, so a scripted
592/// responder can drive the exact wiring — the `max_attempts == 0` immediate-timeout case, the
593/// `1..=max_attempts` bound, and the `attempt < max_attempts` inter-poll sleep guard — under
594/// zero-duration policies. (The live two-call round trip stays covered by the `#[ignore]`d network
595/// test.)
596///
597/// The `initial_delay` is waited once before the first poll: the statement is generated
598/// asynchronously, so an immediate GetStatement almost always reports `1019` ("still generating") and
599/// would burn one of the bounded attempts on a near-certain miss. It is skipped when no polls are
600/// budgeted (`max_attempts == 0`), so that documented "no polling requested" case fails over
601/// immediately with [`IbkrFlexError::PollTimeout`] rather than sleeping before an empty loop.
602async fn poll_until_ready<F>(
603    policy: &FlexPollPolicy,
604    token: &str,
605    mut poll_once: F,
606) -> Result<String, IbkrFlexError>
607where
608    F: AsyncFnMut() -> Result<(reqwest::StatusCode, String), IbkrFlexError>,
609{
610    if policy.max_attempts > 0 {
611        tokio::time::sleep(policy.initial_delay).await;
612    }
613
614    for attempt in 1..=policy.max_attempts {
615        let (status, body) = poll_once().await?;
616        match interpret_poll_response(status, &body, token)? {
617            GetStatementOutcome::Ready => return Ok(body),
618            GetStatementOutcome::InProgress => {
619                debug!(
620                    attempt,
621                    max = policy.max_attempts,
622                    "Flex statement still generating"
623                );
624                if attempt < policy.max_attempts {
625                    tokio::time::sleep(policy.interval).await;
626                }
627            }
628        }
629    }
630
631    Err(IbkrFlexError::PollTimeout {
632        attempts: policy.max_attempts,
633    })
634}
635
636// ============================================================================
637// Pure, testable response classification
638// ============================================================================
639
640/// A successful SendRequest result: the reference code + poll URL for GetStatement.
641#[derive(Debug)]
642struct SendRequestOk {
643    reference_code: String,
644    url: String,
645}
646
647/// The outcome of classifying a GetStatement poll response. A terminal failure is returned as an
648/// `Err` from [`classify_get_statement`], so this only models the two non-error outcomes.
649#[derive(Debug, PartialEq, Eq)]
650enum GetStatementOutcome {
651    /// The body is a `<FlexQueryResponse>` statement, ready to parse.
652    Ready,
653    /// The service is still generating the statement; poll again.
654    InProgress,
655}
656
657/// The `<FlexStatementResponse>` envelope (SendRequest result and GetStatement "not ready" / error
658/// status). Its fields are child *elements*, not attributes.
659#[derive(Debug, Deserialize)]
660struct FlexStatementResponse {
661    #[serde(rename = "Status", default)]
662    status: String,
663    #[serde(rename = "ReferenceCode", default)]
664    reference_code: Option<String>,
665    #[serde(rename = "Url", default)]
666    url: Option<String>,
667    #[serde(rename = "ErrorCode", default)]
668    error_code: Option<String>,
669    #[serde(rename = "ErrorMessage", default)]
670    error_message: Option<String>,
671}
672
673/// Parse a SendRequest response: success yields the reference code + poll URL; any other status
674/// becomes an [`IbkrFlexError::Flex`].
675fn parse_send_request_response(xml: &str) -> Result<SendRequestOk, IbkrFlexError> {
676    // A SendRequest response is always a `<FlexStatementResponse>` envelope. Reject any other root
677    // element as a parse error up front — otherwise quick_xml's lenient, all-`#[serde(default)]`
678    // deserialization coerces a non-Flex body (e.g. a proxy/CDN HTML error page) into an
679    // empty-status envelope, which would surface as a confusing `Flex { code: "" }`. Mirrors the
680    // root-element check `classify_get_statement` already does, and lets `interpret_send_response`
681    // distinguish a non-Flex body (→ `HttpStatus` under a non-2xx status) from a genuine Flex error.
682    match root_element_name(xml).as_deref() {
683        Some("FlexStatementResponse") => {}
684        Some(other) => {
685            return Err(IbkrFlexError::Parse(format!(
686                "unexpected Flex SendRequest root element <{other}>"
687            )));
688        }
689        None => {
690            return Err(IbkrFlexError::Parse(
691                "empty or unreadable Flex SendRequest response".to_owned(),
692            ));
693        }
694    }
695
696    let resp: FlexStatementResponse = quick_xml::de::from_str(xml).map_err(|e| {
697        IbkrFlexError::Parse(format!("failed to parse Flex SendRequest response: {e}"))
698    })?;
699
700    if resp.status.eq_ignore_ascii_case("Success") {
701        match (nonempty(resp.reference_code), nonempty(resp.url)) {
702            (Some(reference_code), Some(url)) => {
703                // The poll URL is broker-supplied and is fetched next with the Flex token in its
704                // `t=` query parameter. Refuse a non-HTTPS URL so the credential can never be sent
705                // over an unencrypted connection (a scheme check only — no brittle host allowlist).
706                if !url.starts_with("https://") {
707                    return Err(IbkrFlexError::Parse(format!(
708                        "Flex SendRequest returned a non-HTTPS poll URL; refusing to send credentials over it: {url}"
709                    )));
710                }
711                Ok(SendRequestOk {
712                    reference_code,
713                    url,
714                })
715            }
716            _ => Err(IbkrFlexError::Parse(
717                "Flex SendRequest reported success but is missing ReferenceCode/Url".to_owned(),
718            )),
719        }
720    } else {
721        Err(flex_error(resp, "SendRequest"))
722    }
723}
724
725/// Classify a GetStatement poll response by its root element: a `<FlexQueryResponse>` is the ready
726/// statement; a `<FlexStatementResponse>` is either "still generating" (`ErrorCode 1019`) or a
727/// terminal error.
728fn classify_get_statement(xml: &str) -> Result<GetStatementOutcome, IbkrFlexError> {
729    match root_element_name(xml).as_deref() {
730        Some("FlexQueryResponse") => Ok(GetStatementOutcome::Ready),
731        Some("FlexStatementResponse") => {
732            let resp: FlexStatementResponse = quick_xml::de::from_str(xml).map_err(|e| {
733                IbkrFlexError::Parse(format!(
734                    "failed to parse Flex GetStatement status response: {e}"
735                ))
736            })?;
737            // "Generation in progress" always arrives as ErrorCode 1019 *with* Status=Warn per the
738            // IBKR Flex v3 protocol (see the `GET_IN_PROGRESS` fixture). Require both: a 1019 under
739            // Status=Fail would be a terminal error IBKR mislabeled, and retrying it would only burn
740            // the poll budget before timing out. The status compare is case-insensitive to tolerate
741            // label-casing variance.
742            if resp.error_code.as_deref() == Some(GENERATION_IN_PROGRESS_CODE)
743                && resp.status.eq_ignore_ascii_case("warn")
744            {
745                Ok(GetStatementOutcome::InProgress)
746            } else {
747                Err(flex_error(resp, "GetStatement"))
748            }
749        }
750        Some(other) => Err(IbkrFlexError::Parse(format!(
751            "unexpected Flex GetStatement root element <{other}>"
752        ))),
753        None => Err(IbkrFlexError::Parse(
754            "empty or unreadable Flex GetStatement response".to_owned(),
755        )),
756    }
757}
758
759/// Build an [`IbkrFlexError::Flex`] from a non-success status envelope.
760fn flex_error(resp: FlexStatementResponse, stage: &str) -> IbkrFlexError {
761    IbkrFlexError::Flex {
762        code: resp.error_code.unwrap_or_default(),
763        message: resp
764            .error_message
765            .unwrap_or_else(|| format!("Flex {stage} returned non-success status {}", resp.status)),
766    }
767}
768
769/// Scrub the Flex `token` from, and bound, a terminal [`IbkrFlexError::Flex`]'s fields.
770///
771/// A `Flex` error's `message` is the `<ErrorMessage>` element lifted verbatim from a server-supplied
772/// body — the same untrusted source [`IbkrFlexError::HttpStatus`] and [`IbkrFlexError::Parse`]
773/// scrub. A body that reflected the request line (a misconfigured proxy echoing `?t=<TOKEN>` into
774/// its `<ErrorMessage>`) could otherwise carry the credential into the stored error, and an
775/// oversized element could bloat it. `code` is a short structured field in practice, but running it
776/// through the same [`sanitize_error_body`] (redact-before-bound, capped at [`MAX_ERROR_BODY_BYTES`])
777/// costs nothing on this error path and closes the gap uniformly — the scrub is a no-op for a real
778/// numeric Flex code that does not contain the token.
779fn scrub_flex_error(code: String, message: String, token: &str) -> IbkrFlexError {
780    IbkrFlexError::Flex {
781        code: sanitize_error_body(&code, token),
782        message: sanitize_error_body(&message, token),
783    }
784}
785
786/// Interpret a SendRequest response given its HTTP `status` and `body`.
787///
788/// The body is parsed as a Flex envelope **first**, so an IBKR application error that arrives under
789/// a non-2xx status still surfaces as the richer [`IbkrFlexError::Flex`] (e.g. `1003` invalid
790/// token). A body that is *not* a recognizable Flex envelope under a non-success status becomes
791/// [`IbkrFlexError::HttpStatus`] (a proxy/CDN error page) rather than a misleading XML parse error;
792/// the same malformed body under a 2xx status stays a genuine [`IbkrFlexError::Parse`] — with its
793/// message token-scrubbed, since an XML deserialiser embeds fragments of the offending input. A
794/// genuine Flex error envelope surfaces as [`IbkrFlexError::Flex`], its fields likewise scrubbed and
795/// bounded ([`scrub_flex_error`]), since `<ErrorMessage>` is server-supplied free text.
796fn interpret_send_response(
797    status: reqwest::StatusCode,
798    body: &str,
799    token: &str,
800) -> Result<SendRequestOk, IbkrFlexError> {
801    match parse_send_request_response(body) {
802        Err(IbkrFlexError::Parse(_)) if !status.is_success() => {
803            Err(http_status_error(status, body, token))
804        }
805        Err(IbkrFlexError::Parse(message)) => Err(finish_parse_error(message, Some(token))),
806        Err(IbkrFlexError::Flex { code, message }) => Err(scrub_flex_error(code, message, token)),
807        other => other,
808    }
809}
810
811/// Interpret a GetStatement poll response given its HTTP `status` and `body`.
812///
813/// The body is classified **first**, so a `1019` "still generating" that IBKR returns under a
814/// non-2xx status is still recognized as retryable — previously `error_for_status` aborted the poll
815/// before the body was classified. A body that is not a recognizable Flex envelope under a
816/// non-success status becomes [`IbkrFlexError::HttpStatus`]; a terminal Flex error envelope under a
817/// non-2xx status stays the richer [`IbkrFlexError::Flex`], its fields scrubbed and bounded
818/// ([`scrub_flex_error`]) since `<ErrorMessage>` is server-supplied free text.
819fn interpret_poll_response(
820    status: reqwest::StatusCode,
821    body: &str,
822    token: &str,
823) -> Result<GetStatementOutcome, IbkrFlexError> {
824    match classify_get_statement(body) {
825        Err(IbkrFlexError::Parse(_)) if !status.is_success() => {
826            Err(http_status_error(status, body, token))
827        }
828        Err(IbkrFlexError::Parse(message)) => Err(finish_parse_error(message, Some(token))),
829        Err(IbkrFlexError::Flex { code, message }) => Err(scrub_flex_error(code, message, token)),
830        other => other,
831    }
832}
833
834/// Build an [`IbkrFlexError::Parse`] from an **unbounded** message: redact `token` when one is in
835/// scope, then bound the result to [`MAX_ERROR_BODY_BYTES`].
836///
837/// A parse failure message embeds the deserialiser's own rendering of the offending input —
838/// quick-xml's `DeError::UnexpectedStart`, for instance, carries the raw tag bytes it choked on. A
839/// body that reflected the request line (a misconfigured proxy echoing `?t=<TOKEN>`) could
840/// therefore carry the credential into the stored error, which [`IbkrFlexError::HttpStatus`] is
841/// already protected against but this variant was not.
842///
843/// This is the single **finaliser** for a `Parse` that leaves the module — not the single
844/// construction point. The raw parsers [`parse_send_request_response`] and [`classify_get_statement`]
845/// build *unbounded, unscrubbed* `Parse` values directly, but they are private and every production
846/// caller routes their `Err(Parse(_))` back through here (or, for a non-2xx non-envelope body,
847/// through [`http_status_error`]): see [`interpret_send_response`]/[`interpret_poll_response`], and
848/// [`parse_corporate_actions_scrubbed`](corporate_action::parse_corporate_actions_scrubbed) for the
849/// statement re-parse. Keeping the redact-before-bound ordering in exactly one place is what makes
850/// that safe: it is a security invariant, not an implementation detail — redaction matches the full
851/// token, so bounding first can leave a credential straddling the cap present only as an unmatched
852/// prefix fragment. A finaliser duplicated across callers would be one edit away from diverging on
853/// it.
854///
855/// Maintainer corollary: the guarantee is caller discipline over two private parsers, **not** a
856/// property the type system enforces. A *new* call site for either raw parser must route its `Parse`
857/// through this function (or `http_status_error`) too, or it reintroduces the unbounded/unscrubbed
858/// leak this exists to prevent.
859///
860/// `message` must therefore arrive unbounded — a caller that has already truncated at
861/// [`MAX_ERROR_BODY_BYTES`] has already lost the guarantee, and passing it here cannot recover it.
862/// Both branches end at the same cap and differ only in whether a redaction pass runs first, so the
863/// bound is applied exactly once either way.
864fn finish_parse_error(message: String, token: Option<&str>) -> IbkrFlexError {
865    IbkrFlexError::Parse(match token {
866        Some(token) => sanitize_error_body(&message, token),
867        None => truncate_str(&message, MAX_ERROR_BODY_BYTES),
868    })
869}
870
871/// Build an [`IbkrFlexError::HttpStatus`] from a non-success response, bounding and token-scrubbing
872/// the body.
873fn http_status_error(status: reqwest::StatusCode, body: &str, token: &str) -> IbkrFlexError {
874    IbkrFlexError::HttpStatus {
875        status: status.as_u16(),
876        body: sanitize_error_body(body, token),
877    }
878}
879
880/// Redact the Flex `token` — both its raw form and its `application/x-www-form-urlencoded` wire
881/// form — from `body`, then bound the result to [`MAX_ERROR_BODY_BYTES`] (on a UTF-8 char boundary).
882///
883/// The token rides in the request URL's `t=` query parameter, attached via reqwest's `.query(&[…])`,
884/// which serialises through [`url::Url::query_pairs_mut`] — WHATWG form-urlencoding, which escapes
885/// space as `+` and leaves a *narrower* byte set unescaped than RFC-3986 percent-encoding (e.g. `~`
886/// is escaped here). A misconfigured proxy or WAF that echoes the request line into its error page
887/// would reflect *that* wire form, not the raw token, so both are scrubbed. The wire form is computed
888/// with [`url::form_urlencoded::byte_serialize`] — the same primitive `url`'s serializer (and thus
889/// reqwest's `.query()`) uses internally, not a hand-rolled percent-encoding table — so it cannot
890/// drift from what is actually sent (`sanitize_error_body_scrubs_reqwests_wire_encoded_token` pins
891/// this against reqwest's real request encoding).
892///
893/// This is **best-effort defense-in-depth, not an absolute guarantee**: an intermediary could still
894/// transform the reflected value in a way this scrub does not anticipate (further re-encoding,
895/// HTML-entity escaping, or truncation that splits the token). For a real IBKR token — a numeric
896/// string that encodes to itself — the raw scrub already covers it and the wire-form pass is a no-op.
897///
898/// How `body` is bounded depends on the caller, and only one of the two arrives pre-bounded:
899///
900/// - Via [`http_status_error`] (non-success status), `body` has already passed through
901///   [`read_body_capped`] at [`MAX_ERROR_BODY_DOWNLOAD_BYTES`] (64 KiB) in
902///   [`IbkrFlexClient::get_with_query`]. The two caps are complementary, not redundant — the outer
903///   one bounds memory during the network read itself, this one bounds what is retained in the
904///   stored error. The 64× gap between them also means a token fragment straddling the *download*
905///   boundary cannot survive into the final message: the trailing partial token sits far past
906///   [`MAX_ERROR_BODY_BYTES`] and is truncated away.
907/// - Via [`finish_parse_error`] (2xx status), `body` is a deserialiser message — raised either
908///   while interpreting a SendRequest/GetStatement response or while re-parsing a statement in
909///   [`parse_corporate_actions_scrubbed`](corporate_action::parse_corporate_actions_scrubbed) —
910///   derived from an **unbounded** `response.text()` read,
911///   so no download-level cap applies to it. This function therefore applies the coarse
912///   [`MAX_ERROR_BODY_DOWNLOAD_BYTES`] bound itself, on entry, reproducing the same 64× gap the
913///   non-success path gets for free — wide enough that it cannot split a token whose fragment would
914///   then survive into the retained message, but narrow enough that the redaction passes below do
915///   not scan an unbounded string.
916///
917/// Callers must pass a message that has **not** already been truncated at
918/// [`MAX_ERROR_BODY_BYTES`]: redaction matches the full token, so a caller-side cut at the same
919/// width this function retains could leave a straddling credential as an unmatched prefix. Both 2xx
920/// callers hand over their message unbounded for exactly this reason.
921fn sanitize_error_body(body: &str, token: &str) -> String {
922    // Bound the input before the redaction passes. `redact` allocates a full copy per pass even when
923    // the needle is absent, and a 2xx `Parse` message derives from an unbounded read, so without
924    // this an oversized message is copied twice before the trailing truncate discards nearly all of
925    // it. Slicing (rather than `truncate_str`) keeps the common under-cap case allocation-free, and
926    // the cap sits 64× above what is retained, so it cannot create the straddling-fragment problem
927    // the redact-then-bound ordering below exists to avoid.
928    let body = if body.len() > MAX_ERROR_BODY_DOWNLOAD_BYTES {
929        &body[..body.floor_char_boundary(MAX_ERROR_BODY_DOWNLOAD_BYTES)]
930    } else {
931        body
932    };
933    // Redact the token over the (coarsely bounded) body first — order matters for correctness, not
934    // just cost:
935    // bounding first could leave a credential that straddles the byte cap present only as a prefix,
936    // which the full-token `contains`/`replace` would then miss, and redacting after bounding could
937    // also push the result back over the cap (`[REDACTED]` is longer than a short token). Scanning
938    // the whole body only runs on the error path, so the extra pass is acceptable.
939    let mut scrubbed = redact(body, token);
940    // Also scrub the encoded form the token actually takes on the wire, so a reflected request line
941    // is covered too — but only when it differs from the raw token (a no-op for numeric tokens).
942    if !token.is_empty() {
943        let wire_form: String = url::form_urlencoded::byte_serialize(token.as_bytes()).collect();
944        if wire_form.as_str() != token {
945            scrubbed = redact(&scrubbed, &wire_form);
946        }
947    }
948    // Then bound to MAX_ERROR_BODY_BYTES so a large proxy/CDN error page can't bloat the error.
949    // `floor_char_boundary` rounds the cap down to a UTF-8 char boundary (there is always one at or
950    // below it), keeping the truncated string valid — which `String::truncate` requires. Matches the
951    // idiom used across the crate (e.g. `massive::error`, `massive::rest`, `binance::error`).
952    if scrubbed.len() > MAX_ERROR_BODY_BYTES {
953        scrubbed.truncate(scrubbed.floor_char_boundary(MAX_ERROR_BODY_BYTES));
954    }
955    scrubbed
956}
957
958/// Replace every occurrence of `needle` in `body` with `[REDACTED]`, always returning an owned
959/// `String` (the caller chains two redaction passes over the result, so a borrowing return would not
960/// help). The `contains` guard skips only the `replace` scan when `needle` is absent — it does not
961/// avoid the allocation. An empty `needle` is never treated as a match: `str::replace` with an empty
962/// pattern would splice the replacement between every character.
963fn redact(body: &str, needle: &str) -> String {
964    if !needle.is_empty() && body.contains(needle) {
965        body.replace(needle, "[REDACTED]")
966    } else {
967        body.to_owned()
968    }
969}
970
971/// Read the name of the first (root) element of an XML document, ignoring the prolog/comments.
972///
973/// Returns a [`SmolStr`] so the expected root names (`FlexQueryResponse`, `FlexStatementResponse`)
974/// are compared without a heap allocation (both fit `SmolStr`'s inline buffer).
975fn root_element_name(xml: &str) -> Option<SmolStr> {
976    let mut reader = Reader::from_str(xml);
977    loop {
978        match reader.read_event() {
979            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
980                return Some(SmolStr::new(e.name().as_ref()));
981            }
982            Ok(Event::Eof) | Err(_) => return None,
983            Ok(_) => {}
984        }
985    }
986}
987
988/// Trim an optional string, collapsing empty/whitespace-only values to `None`.
989///
990/// Reuses the original allocation when the value needs no trimming (the common case for
991/// well-formed Flex attributes), only allocating when whitespace is actually stripped.
992fn nonempty(value: Option<String>) -> Option<String> {
993    value.and_then(|v| {
994        let trimmed = v.trim();
995        if trimmed.is_empty() {
996            None
997        } else if trimmed.len() == v.len() {
998            Some(v)
999        } else {
1000            Some(trimmed.to_owned())
1001        }
1002    })
1003}
1004
1005#[cfg(test)]
1006#[allow(clippy::unwrap_used, clippy::expect_used)] // Tests should panic on unexpected values.
1007mod tests {
1008    use super::*;
1009
1010    const SEND_SUCCESS: &str = r#"<FlexStatementResponse timestamp="15 January, 2025 03:00 PM EST">
1011        <Status>Success</Status>
1012        <ReferenceCode>1234567890</ReferenceCode>
1013        <Url>https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement</Url>
1014    </FlexStatementResponse>"#;
1015
1016    const SEND_FAIL: &str = r#"<FlexStatementResponse timestamp="15 January, 2025 03:00 PM EST">
1017        <Status>Fail</Status>
1018        <ErrorCode>1003</ErrorCode>
1019        <ErrorMessage>Statement could not be generated at this time. Please try again shortly.</ErrorMessage>
1020    </FlexStatementResponse>"#;
1021
1022    const GET_IN_PROGRESS: &str = r#"<FlexStatementResponse timestamp="15 January, 2025 03:00 PM EST">
1023        <Status>Warn</Status>
1024        <ErrorCode>1019</ErrorCode>
1025        <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1026    </FlexStatementResponse>"#;
1027
1028    const GET_FAIL: &str = r#"<FlexStatementResponse>
1029        <Status>Fail</Status>
1030        <ErrorCode>1020</ErrorCode>
1031        <ErrorMessage>Invalid request or unable to validate request.</ErrorMessage>
1032    </FlexStatementResponse>"#;
1033
1034    const GET_READY: &str = r#"<?xml version="1.0"?>
1035        <FlexQueryResponse queryName="Activity" type="AF">
1036          <FlexStatements count="1">
1037            <FlexStatement accountId="U1"><CorporateActions /></FlexStatement>
1038          </FlexStatements>
1039        </FlexQueryResponse>"#;
1040
1041    #[test]
1042    fn send_request_success_extracts_reference_and_url() {
1043        let ok = parse_send_request_response(SEND_SUCCESS).unwrap();
1044        assert_eq!(ok.reference_code, "1234567890");
1045        assert_eq!(
1046            ok.url,
1047            "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement"
1048        );
1049    }
1050
1051    #[test]
1052    fn send_request_failure_is_a_flex_error() {
1053        match parse_send_request_response(SEND_FAIL) {
1054            Err(IbkrFlexError::Flex { code, message }) => {
1055                assert_eq!(code, "1003");
1056                assert!(message.contains("could not be generated"));
1057            }
1058            other => panic!("expected Flex error, got {other:?}"),
1059        }
1060    }
1061
1062    #[test]
1063    fn send_request_success_without_url_is_a_parse_error() {
1064        let xml = r#"<FlexStatementResponse><Status>Success</Status>
1065            <ReferenceCode>123</ReferenceCode></FlexStatementResponse>"#;
1066        assert!(matches!(
1067            parse_send_request_response(xml),
1068            Err(IbkrFlexError::Parse(_))
1069        ));
1070    }
1071
1072    #[test]
1073    fn send_request_non_https_url_is_rejected() {
1074        // A success response whose poll URL is plain HTTP must be refused: the token rides in that
1075        // URL's `t=` query parameter and must never traverse an unencrypted connection.
1076        let xml = r#"<FlexStatementResponse><Status>Success</Status>
1077            <ReferenceCode>123</ReferenceCode>
1078            <Url>http://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement</Url></FlexStatementResponse>"#;
1079        assert!(matches!(
1080            parse_send_request_response(xml),
1081            Err(IbkrFlexError::Parse(_))
1082        ));
1083    }
1084
1085    #[test]
1086    fn send_request_non_envelope_root_is_a_parse_error() {
1087        // A non-<FlexStatementResponse> body (e.g. a proxy/CDN HTML error page) must be a parse
1088        // error, not coerced by lenient all-`#[serde(default)]` deserialization into a confusing
1089        // empty-status `Flex` error. This is what lets `interpret_send_response` treat it as an
1090        // HttpStatus under a non-2xx status.
1091        for body in [
1092            "<html><body>502 Bad Gateway</body></html>",
1093            "<not-flex/>",
1094            "",
1095        ] {
1096            assert!(
1097                matches!(
1098                    parse_send_request_response(body),
1099                    Err(IbkrFlexError::Parse(_))
1100                ),
1101                "expected Parse for body {body:?}"
1102            );
1103        }
1104    }
1105
1106    #[test]
1107    fn get_statement_ready_is_classified_ready() {
1108        assert_eq!(
1109            classify_get_statement(GET_READY).unwrap(),
1110            GetStatementOutcome::Ready
1111        );
1112    }
1113
1114    #[test]
1115    fn get_statement_generation_in_progress_is_classified_in_progress() {
1116        assert_eq!(
1117            classify_get_statement(GET_IN_PROGRESS).unwrap(),
1118            GetStatementOutcome::InProgress
1119        );
1120    }
1121
1122    #[test]
1123    fn get_statement_in_progress_code_is_case_insensitive_on_status() {
1124        // The `Status=Warn` check uses `eq_ignore_ascii_case`, so casing variance (e.g. all-caps
1125        // `WARN`) must still classify a 1019 as retryable rather than fail fast on a terminal error.
1126        const WARN_UPPER_1019: &str = r#"<FlexStatementResponse>
1127            <Status>WARN</Status>
1128            <ErrorCode>1019</ErrorCode>
1129            <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1130        </FlexStatementResponse>"#;
1131        assert_eq!(
1132            classify_get_statement(WARN_UPPER_1019).unwrap(),
1133            GetStatementOutcome::InProgress
1134        );
1135    }
1136
1137    #[test]
1138    fn get_statement_in_progress_code_under_fail_status_is_a_flex_error() {
1139        // ErrorCode 1019 is only retryable under Status=Warn. If IBKR ever returns 1019 under
1140        // Status=Fail, treat it as a terminal error instead of retrying a failure until the poll
1141        // budget is exhausted.
1142        const FAIL_1019: &str = r#"<FlexStatementResponse>
1143            <Status>Fail</Status>
1144            <ErrorCode>1019</ErrorCode>
1145            <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1146        </FlexStatementResponse>"#;
1147        match classify_get_statement(FAIL_1019) {
1148            Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1019"),
1149            other => panic!("expected terminal Flex error, got {other:?}"),
1150        }
1151    }
1152
1153    #[test]
1154    fn get_statement_1019_without_status_is_a_flex_error() {
1155        // `Status` deserializes via `#[serde(default)]`, so a 1019 envelope with no `<Status>`
1156        // element yields `status == ""`. Retryability requires Status=Warn, so a missing status is
1157        // treated as terminal rather than retried until the poll budget is exhausted.
1158        const NO_STATUS_1019: &str = r#"<FlexStatementResponse>
1159            <ErrorCode>1019</ErrorCode>
1160            <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1161        </FlexStatementResponse>"#;
1162        match classify_get_statement(NO_STATUS_1019) {
1163            Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1019"),
1164            other => panic!("expected terminal Flex error, got {other:?}"),
1165        }
1166    }
1167
1168    #[test]
1169    fn get_statement_1019_under_success_status_is_a_flex_error() {
1170        // 1019 is only retryable under Status=Warn. A 1019 reported under Status=Success is a
1171        // self-contradictory envelope; treat it as terminal rather than retrying it until the poll
1172        // budget is exhausted.
1173        const SUCCESS_1019: &str = r#"<FlexStatementResponse>
1174            <Status>Success</Status>
1175            <ErrorCode>1019</ErrorCode>
1176            <ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage>
1177        </FlexStatementResponse>"#;
1178        match classify_get_statement(SUCCESS_1019) {
1179            Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1019"),
1180            other => panic!("expected terminal Flex error, got {other:?}"),
1181        }
1182    }
1183
1184    #[test]
1185    fn get_statement_terminal_failure_is_a_flex_error() {
1186        match classify_get_statement(GET_FAIL) {
1187            Err(IbkrFlexError::Flex { code, message }) => {
1188                assert_eq!(code, "1020");
1189                assert!(message.contains("Invalid request"));
1190            }
1191            other => panic!("expected Flex error, got {other:?}"),
1192        }
1193    }
1194
1195    #[test]
1196    fn root_element_name_skips_prolog() {
1197        assert_eq!(
1198            root_element_name(GET_READY).as_deref(),
1199            Some("FlexQueryResponse")
1200        );
1201        assert_eq!(
1202            root_element_name(SEND_SUCCESS).as_deref(),
1203            Some("FlexStatementResponse")
1204        );
1205        assert_eq!(root_element_name("").as_deref(), None);
1206    }
1207
1208    #[test]
1209    fn config_debug_redacts_token() {
1210        let config =
1211            IbkrFlexConfig::new("super-secret-token", "987654").expect("valid credentials");
1212        let debug = format!("{config:?}");
1213        assert!(
1214            !debug.contains("super-secret-token"),
1215            "token must be redacted"
1216        );
1217        assert!(debug.contains("987654"), "query_id is not a secret");
1218    }
1219
1220    #[test]
1221    fn client_debug_hides_token() {
1222        let client = IbkrFlexClient::new(
1223            IbkrFlexConfig::new("super-secret-token", "987654").expect("valid credentials"),
1224        )
1225        .expect("HTTP client should build");
1226        let debug = format!("{client:?}");
1227        assert!(
1228            !debug.contains("super-secret-token"),
1229            "token must not leak via Debug"
1230        );
1231        assert!(debug.contains("987654"));
1232    }
1233
1234    #[test]
1235    fn new_rejects_empty_or_whitespace_token() {
1236        for token in ["", "   ", "\t\n"] {
1237            match IbkrFlexConfig::new(token, "987654") {
1238                Err(IbkrFlexError::InvalidCredential(msg)) => assert!(
1239                    msg.contains("token"),
1240                    "error should name the `token` field, got: {msg}"
1241                ),
1242                other => panic!("expected InvalidCredential for token {token:?}, got: {other:?}"),
1243            }
1244        }
1245    }
1246
1247    #[test]
1248    fn new_rejects_empty_or_whitespace_query_id() {
1249        for query_id in ["", "   ", "\t\n"] {
1250            match IbkrFlexConfig::new("valid-token", query_id) {
1251                Err(IbkrFlexError::InvalidCredential(msg)) => assert!(
1252                    msg.contains("query_id"),
1253                    "error should name the `query_id` field, got: {msg}"
1254                ),
1255                other => {
1256                    panic!("expected InvalidCredential for query_id {query_id:?}, got: {other:?}")
1257                }
1258            }
1259        }
1260    }
1261
1262    #[test]
1263    fn new_trims_surrounding_whitespace() {
1264        let config = IbkrFlexConfig::new("  super-secret-token  ", "  987654  ")
1265            .expect("padded-but-nonempty credentials are valid");
1266        // Debug redacts the token, so `query_id` is the only observable window into the stored
1267        // (trimmed) values. Confirm the surrounding whitespace was stripped before storage.
1268        let debug = format!("{config:?}");
1269        assert!(
1270            debug.contains("\"987654\""),
1271            "query_id should be trimmed to `987654`, got: {debug}"
1272        );
1273        assert!(
1274            !debug.contains("  987654"),
1275            "surrounding whitespace should not survive into the stored query_id, got: {debug}"
1276        );
1277    }
1278
1279    // ----- status/body interpretation (part (a): read body before branching on status) -----
1280
1281    #[test]
1282    fn interpret_send_response_success_parses() {
1283        let ok = interpret_send_response(reqwest::StatusCode::OK, SEND_SUCCESS, "tok").unwrap();
1284        assert_eq!(ok.reference_code, "1234567890");
1285    }
1286
1287    #[test]
1288    fn interpret_send_response_flex_error_preserved_under_non_2xx() {
1289        // An IBKR application error (Fail/1003) that arrives under a non-success HTTP status must
1290        // still surface as the richer Flex error, not be masked as a raw transport status.
1291        match interpret_send_response(reqwest::StatusCode::UNAUTHORIZED, SEND_FAIL, "tok") {
1292            Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1003"),
1293            other => panic!("expected Flex error, got {other:?}"),
1294        }
1295    }
1296
1297    #[test]
1298    fn interpret_send_response_non_flex_body_under_non_2xx_is_http_status() {
1299        // A proxy/CDN error page (not a Flex envelope) under a non-2xx status becomes HttpStatus,
1300        // carrying the status + body rather than being discarded (as `error_for_status` did) or
1301        // surfacing as a misleading XML parse error.
1302        let body = "<html><body>502 Bad Gateway</body></html>";
1303        match interpret_send_response(reqwest::StatusCode::BAD_GATEWAY, body, "tok") {
1304            Err(IbkrFlexError::HttpStatus { status, body }) => {
1305                assert_eq!(status, 502);
1306                assert!(body.contains("Bad Gateway"), "diagnostic body preserved");
1307            }
1308            other => panic!("expected HttpStatus, got {other:?}"),
1309        }
1310    }
1311
1312    #[test]
1313    fn interpret_send_response_garbage_under_2xx_is_parse_error() {
1314        // A malformed body under a *success* status is a genuine parse failure, not a transport
1315        // status — the HttpStatus fallback must fire only on non-success statuses.
1316        assert!(matches!(
1317            interpret_send_response(reqwest::StatusCode::OK, "<not-flex/>", "tok"),
1318            Err(IbkrFlexError::Parse(_))
1319        ));
1320    }
1321
1322    #[test]
1323    fn interpret_poll_response_ready_and_in_progress() {
1324        assert_eq!(
1325            interpret_poll_response(reqwest::StatusCode::OK, GET_READY, "tok").unwrap(),
1326            GetStatementOutcome::Ready
1327        );
1328        assert_eq!(
1329            interpret_poll_response(reqwest::StatusCode::OK, GET_IN_PROGRESS, "tok").unwrap(),
1330            GetStatementOutcome::InProgress
1331        );
1332    }
1333
1334    #[test]
1335    fn interpret_poll_response_1019_under_non_2xx_is_still_retryable() {
1336        // R9 retry-path fix: a 1019 "still generating" that IBKR returns under a non-success HTTP
1337        // status must remain retryable. Previously `error_for_status` aborted the poll before the
1338        // body was ever classified, turning a transient generation delay into a hard failure.
1339        assert_eq!(
1340            interpret_poll_response(
1341                reqwest::StatusCode::SERVICE_UNAVAILABLE,
1342                GET_IN_PROGRESS,
1343                "tok"
1344            )
1345            .unwrap(),
1346            GetStatementOutcome::InProgress
1347        );
1348    }
1349
1350    #[test]
1351    fn interpret_poll_response_terminal_flex_error_preserved_under_non_2xx() {
1352        match interpret_poll_response(reqwest::StatusCode::BAD_REQUEST, GET_FAIL, "tok") {
1353            Err(IbkrFlexError::Flex { code, .. }) => assert_eq!(code, "1020"),
1354            other => panic!("expected Flex error, got {other:?}"),
1355        }
1356    }
1357
1358    #[test]
1359    fn interpret_send_response_flex_error_message_is_token_scrubbed() {
1360        // A genuine Flex error envelope's <ErrorMessage> is server-supplied free text — a
1361        // misconfigured proxy could reflect the request line (with the `t=` token) into it. The
1362        // stored Flex error must never carry the credential, the same protection HttpStatus/Parse
1363        // already have. The Flex code is still preserved so consumers can match on it.
1364        let token = "super-secret-token";
1365        let body = format!(
1366            "<FlexStatementResponse><Status>Fail</Status><ErrorCode>1003</ErrorCode>\
1367             <ErrorMessage>invalid token for GET /SendRequest?t={token}&amp;q=1&amp;v=3</ErrorMessage>\
1368             </FlexStatementResponse>"
1369        );
1370        match interpret_send_response(reqwest::StatusCode::OK, &body, token) {
1371            Err(IbkrFlexError::Flex { code, message }) => {
1372                assert_eq!(code, "1003", "the Flex code is preserved for matching");
1373                assert!(
1374                    !message.contains(token),
1375                    "token must be redacted, got: {message}"
1376                );
1377                assert!(message.contains("[REDACTED]"));
1378            }
1379            other => panic!("expected Flex, got {other:?}"),
1380        }
1381    }
1382
1383    #[test]
1384    fn interpret_poll_response_flex_error_message_is_token_scrubbed() {
1385        // Sibling of the SendRequest case above, for the GetStatement poll path.
1386        let token = "super-secret-token";
1387        let body = format!(
1388            "<FlexStatementResponse><Status>Fail</Status><ErrorCode>1020</ErrorCode>\
1389             <ErrorMessage>bad reference for GET /GetStatement?t={token}&amp;q=1&amp;v=3</ErrorMessage>\
1390             </FlexStatementResponse>"
1391        );
1392        match interpret_poll_response(reqwest::StatusCode::OK, &body, token) {
1393            Err(IbkrFlexError::Flex { code, message }) => {
1394                assert_eq!(code, "1020");
1395                assert!(
1396                    !message.contains(token),
1397                    "token must be redacted, got: {message}"
1398                );
1399                assert!(message.contains("[REDACTED]"));
1400            }
1401            other => panic!("expected Flex, got {other:?}"),
1402        }
1403    }
1404
1405    #[test]
1406    fn interpret_send_response_flex_error_code_is_token_scrubbed() {
1407        // `scrub_flex_error` runs `code` through the same redact-before-bound path as `message`, so a
1408        // token reflected into a `<ErrorCode>` (a no-op for the real numeric codes IBKR returns, but
1409        // the field is still server-supplied) must not survive into the stored error either.
1410        let token = "super-secret-token";
1411        let body = format!(
1412            "<FlexStatementResponse><Status>Fail</Status>\
1413             <ErrorCode>bogus t={token}</ErrorCode>\
1414             <ErrorMessage>rejected</ErrorMessage></FlexStatementResponse>"
1415        );
1416        match interpret_send_response(reqwest::StatusCode::OK, &body, token) {
1417            Err(IbkrFlexError::Flex { code, message }) => {
1418                assert!(
1419                    !code.contains(token),
1420                    "token must be redacted from code, got: {code}"
1421                );
1422                assert!(code.contains("[REDACTED]"));
1423                assert_eq!(message, "rejected");
1424            }
1425            other => panic!("expected Flex, got {other:?}"),
1426        }
1427    }
1428
1429    #[test]
1430    fn flex_error_message_is_bounded() {
1431        // A `Flex` message derives from an unbounded 2xx `response.text()` read, so an adversarial or
1432        // buggy server returning a well-formed Flex error envelope with a huge <ErrorMessage> must be
1433        // bounded like HttpStatus/Parse, not stored whole. Multi-byte chars confirm the cap lands on
1434        // a char boundary rather than panicking.
1435        let body = format!(
1436            "<FlexStatementResponse><Status>Fail</Status><ErrorCode>1003</ErrorCode>\
1437             <ErrorMessage>{}</ErrorMessage></FlexStatementResponse>",
1438            "€".repeat(1000) // 3000 bytes, exceeds the 1024-byte cap
1439        );
1440        match interpret_send_response(reqwest::StatusCode::OK, &body, "tok") {
1441            Err(IbkrFlexError::Flex { message, .. }) => assert!(
1442                message.len() <= MAX_ERROR_BODY_BYTES,
1443                "message bounded to the cap, got {} bytes",
1444                message.len()
1445            ),
1446            other => panic!("expected Flex, got {other:?}"),
1447        }
1448    }
1449
1450    #[test]
1451    fn interpret_poll_response_non_flex_body_under_non_2xx_is_http_status() {
1452        match interpret_poll_response(
1453            reqwest::StatusCode::GATEWAY_TIMEOUT,
1454            "upstream request timed out",
1455            "tok",
1456        ) {
1457            Err(IbkrFlexError::HttpStatus { status, body }) => {
1458                assert_eq!(status, 504);
1459                assert!(body.contains("timed out"));
1460            }
1461            other => panic!("expected HttpStatus, got {other:?}"),
1462        }
1463    }
1464
1465    /// Build a `reqwest::Response` directly, with no socket and no mock server, via reqwest's
1466    /// `From<http::Response<_>>` impl. This is what makes the transport-layer read testable without
1467    /// relaxing the client's `https_only(true)` guard — the Flex token rides in the request URL, so
1468    /// a plaintext test server would mean building a client that is not the production one.
1469    fn synthetic_response(status: u16, body: &'static str) -> reqwest::Response {
1470        reqwest::Response::from(
1471            http::Response::builder()
1472                .status(status)
1473                .body(body)
1474                .expect("status and body are valid"),
1475        )
1476    }
1477
1478    #[tokio::test]
1479    async fn read_status_and_body_keeps_a_1019_body_under_a_non_2xx_status() {
1480        // The regression this pins, and the one no `interpret_*` test can catch: reverting
1481        // `get_with_query` to `error_for_status()?.text().await?` discards the body on a non-2xx,
1482        // so the retryable 1019 envelope never reaches `interpret_poll_response` and the poll
1483        // aborts instead of retrying. Here the body must survive the non-success status intact.
1484        let (status, body) = read_status_and_body(synthetic_response(503, GET_IN_PROGRESS))
1485            .await
1486            .expect("a non-success status must still yield its body");
1487
1488        assert_eq!(status, reqwest::StatusCode::SERVICE_UNAVAILABLE);
1489        assert_eq!(
1490            classify_get_statement(&body).expect("body is a Flex envelope"),
1491            GetStatementOutcome::InProgress,
1492            "a 1019 arriving under a non-2xx status must still classify as retryable"
1493        );
1494    }
1495
1496    #[tokio::test]
1497    async fn read_status_and_body_reads_a_success_body_in_full() {
1498        // The success path is the statement payload and must not be routed through the error-path
1499        // cap, which would silently truncate a large `<FlexQueryResponse>`.
1500        let (status, body) = read_status_and_body(synthetic_response(200, GET_READY))
1501            .await
1502            .expect("success body reads");
1503
1504        assert_eq!(status, reqwest::StatusCode::OK);
1505        assert_eq!(body, GET_READY);
1506    }
1507
1508    #[test]
1509    fn http_status_body_is_token_scrubbed() {
1510        // A misconfigured proxy could reflect the request URL (with the `t=` token) into its error
1511        // page; the stored HttpStatus body must never carry the credential.
1512        let token = "super-secret-token";
1513        let body = format!("400 Bad Request: GET /SendRequest?t={token}&q=1&v=3");
1514        match interpret_send_response(reqwest::StatusCode::BAD_REQUEST, &body, token) {
1515            Err(IbkrFlexError::HttpStatus { body, .. }) => {
1516                assert!(!body.contains(token), "token must be redacted, got: {body}");
1517                assert!(body.contains("[REDACTED]"));
1518            }
1519            other => panic!("expected HttpStatus, got {other:?}"),
1520        }
1521    }
1522
1523    #[test]
1524    fn parse_message_is_token_scrubbed_under_2xx() {
1525        // The `HttpStatus` path is covered above; this pins the sibling `Parse` path, which is only
1526        // reachable under a *success* status and so never passes through `http_status_error`. A
1527        // deserialiser message embeds fragments of the offending input, so a body that reflected the
1528        // request line must not carry the credential into the stored error either. Both interpreters
1529        // route their `Parse` arm through `finish_parse_error`; assert both.
1530        let token = "super-secret-token";
1531        // The root-element check formats the tag name verbatim into the message, so naming the root
1532        // after the token is the shortest body that reflects it into a `Parse`.
1533        let body = format!("<{token}/>");
1534
1535        match interpret_send_response(reqwest::StatusCode::OK, &body, token) {
1536            Err(IbkrFlexError::Parse(message)) => {
1537                assert!(
1538                    !message.contains(token),
1539                    "token must be redacted, got: {message}"
1540                );
1541                assert!(message.contains("[REDACTED]"));
1542            }
1543            other => panic!("expected Parse, got {other:?}"),
1544        }
1545
1546        match interpret_poll_response(reqwest::StatusCode::OK, &body, token) {
1547            Err(IbkrFlexError::Parse(message)) => {
1548                assert!(
1549                    !message.contains(token),
1550                    "token must be redacted, got: {message}"
1551                );
1552                assert!(message.contains("[REDACTED]"));
1553            }
1554            other => panic!("expected Parse, got {other:?}"),
1555        }
1556    }
1557
1558    #[test]
1559    fn parse_message_is_bounded_under_2xx() {
1560        // `Parse` messages derive from an unbounded 2xx `response.text()` read, so — unlike the
1561        // `HttpStatus` path — `sanitize_error_body`'s trailing truncate is the *only* thing bounding
1562        // them. Multi-byte chars confirm the cap lands on a char boundary rather than panicking.
1563        let body = format!("<{}/>", "€".repeat(1000)); // 3000 bytes of tag name, exceeds the cap
1564        match interpret_send_response(reqwest::StatusCode::OK, &body, "tok") {
1565            Err(IbkrFlexError::Parse(message)) => {
1566                // Guard against a vacuous pass: the short "empty or unreadable" fallback is under
1567                // the cap regardless, so confirm this really is the input-reflecting message.
1568                assert!(
1569                    message.starts_with("unexpected"),
1570                    "fixture must reach the root-element path, got: {message}"
1571                );
1572                assert!(
1573                    message.len() <= MAX_ERROR_BODY_BYTES,
1574                    "message bounded to the cap, got {} bytes",
1575                    message.len()
1576                );
1577            }
1578            other => panic!("expected Parse, got {other:?}"),
1579        }
1580    }
1581
1582    #[test]
1583    fn http_status_body_is_bounded_on_char_boundary() {
1584        // A large body is truncated to the cap without splitting a multi-byte char (`€` is 3 bytes,
1585        // 1024 is not a multiple of 3, so a naive byte-slice would panic on an invalid boundary).
1586        let body = "€".repeat(1000); // 3000 bytes, exceeds the 1024-byte cap
1587        match interpret_send_response(reqwest::StatusCode::BAD_GATEWAY, &body, "tok") {
1588            Err(IbkrFlexError::HttpStatus { body, .. }) => {
1589                assert!(
1590                    body.len() <= MAX_ERROR_BODY_BYTES,
1591                    "body bounded to the cap"
1592                );
1593                assert!(
1594                    body.chars().all(|c| c == '€'),
1595                    "truncation must land on a char boundary (valid UTF-8)"
1596                );
1597            }
1598            other => panic!("expected HttpStatus, got {other:?}"),
1599        }
1600    }
1601
1602    #[test]
1603    fn http_status_body_scrubs_token_straddling_the_cap() {
1604        // Regression: the token must be redacted even when it straddles the MAX_ERROR_BODY_BYTES cap
1605        // in the *original* body. Redacting after bounding (the previous order) would leave only a
1606        // prefix of the token in the bounded slice, which the full-token match then misses — leaking
1607        // the fragment. Redacting first also keeps the result within the cap (`[REDACTED]` is longer
1608        // than a short token, so replacing after bounding could overrun it).
1609        let token = "SECRETTOKEN1234567890"; // 21 bytes
1610        let prefix = "x".repeat(MAX_ERROR_BODY_BYTES - 9); // token starts 9 bytes before the cap
1611        let body = format!("{prefix}{token}{}", "y".repeat(100)); // > cap; token spans the boundary
1612        match interpret_send_response(reqwest::StatusCode::BAD_REQUEST, &body, token) {
1613            Err(IbkrFlexError::HttpStatus { body, .. }) => {
1614                assert!(
1615                    !body.contains(token),
1616                    "full token must be redacted, got: {body}"
1617                );
1618                assert!(
1619                    !body.contains("SECRET"),
1620                    "no token fragment may survive the boundary, got: {body}"
1621                );
1622                assert!(
1623                    body.len() <= MAX_ERROR_BODY_BYTES,
1624                    "redaction must not push the body back over the cap, len = {}",
1625                    body.len()
1626                );
1627            }
1628            other => panic!("expected HttpStatus, got {other:?}"),
1629        }
1630    }
1631
1632    #[test]
1633    fn corporate_action_parse_scrubs_token_straddling_the_cap() {
1634        // Sibling of `http_status_body_scrubs_token_straddling_the_cap`, for the path that reaches
1635        // `Parse` through `fetch_corporate_actions`. Regression: `parse_corporate_actions` bounds its
1636        // own message, so scrubbing its *result* (the previous shape) redacted an already-truncated
1637        // string — a token straddling the cap survived as an unmatched prefix fragment, because both
1638        // truncations used MAX_ERROR_BODY_BYTES and left no protective gap. The token is now threaded
1639        // into the parse so redaction runs while the message is still unbounded.
1640        let token = "SECRETTOKEN1234567890"; // 21 bytes
1641        // Sweep the token across the cap rather than pinning one offset: the straddling position
1642        // depends on the byte length of the message's fixed prefix, which is not a contract and
1643        // shifts if the wording is ever edited. `straddled` guards against that drift silently
1644        // emptying this test — without it, a reworded message could move the boundary outside the
1645        // swept range and every assertion below would pass vacuously, losing the coverage that is
1646        // the entire reason this test exists.
1647        let mut straddled = false;
1648        for pad in (MAX_ERROR_BODY_BYTES - 80)..(MAX_ERROR_BODY_BYTES - 40) {
1649            let xml = format!("<{}{token}/>", "x".repeat(pad));
1650            // The unscrubbed message the same input would produce. A genuine straddle shows up as a
1651            // non-empty *proper prefix* of the token left at its end — distinct from the token
1652            // landing entirely past the cap (nothing of it remains) or entirely before it (all of it
1653            // remains), neither of which exercises the boundary.
1654            let Err(IbkrFlexError::Parse(plain)) =
1655                corporate_action::parse_corporate_actions_scrubbed(&xml, None)
1656            else {
1657                panic!("expected Parse from the token-less parse at pad {pad}");
1658            };
1659            straddled |= (1..token.len()).any(|cut| plain.ends_with(&token[..cut]));
1660
1661            match corporate_action::parse_corporate_actions_scrubbed(&xml, Some(token)) {
1662                Err(IbkrFlexError::Parse(message)) => {
1663                    assert!(
1664                        !message.contains(token),
1665                        "full token must be redacted at pad {pad}"
1666                    );
1667                    // A straddling leak surfaces as a token *prefix* left at the truncation
1668                    // boundary, so check every prefix length rather than one fixed substring — a
1669                    // 5-byte fragment is as much a credential leak as a 6-byte one.
1670                    for cut in 1..token.len() {
1671                        assert!(
1672                            !message.ends_with(&token[..cut]),
1673                            "a {cut}-byte token fragment survived the boundary at pad {pad}, got: {message}"
1674                        );
1675                    }
1676                    assert!(
1677                        message.len() <= MAX_ERROR_BODY_BYTES,
1678                        "redaction must not push the message over the cap at pad {pad}, len = {}",
1679                        message.len()
1680                    );
1681                }
1682                other => panic!("expected Parse, got {other:?}"),
1683            }
1684        }
1685        assert!(
1686            straddled,
1687            "the swept range no longer places the token across the {MAX_ERROR_BODY_BYTES}-byte cap \
1688             — widen it, or this test proves nothing"
1689        );
1690    }
1691
1692    #[test]
1693    fn corporate_action_parse_without_a_token_is_still_bounded() {
1694        // The public `parse_corporate_actions` has no credential in scope, so it only bounds. Pins
1695        // that routing both callers through one finaliser did not drop the bound on the `None` arm.
1696        let xml = format!("<{}/>", "€".repeat(1000)); // 3000 bytes of tag name, exceeds the cap
1697        match parse_corporate_actions(&xml) {
1698            Err(IbkrFlexError::Parse(message)) => assert!(
1699                message.len() <= MAX_ERROR_BODY_BYTES,
1700                "len = {}",
1701                message.len()
1702            ),
1703            other => panic!("expected Parse, got {other:?}"),
1704        }
1705    }
1706
1707    #[test]
1708    fn max_error_body_bytes_is_the_documented_cap() {
1709        // Pinned against a literal because the public rustdoc on `IbkrFlexError` states this bound as
1710        // "1 KiB" in prose (it cannot link a private constant under
1711        // `deny(rustdoc::private_intra_doc_links)`). Changing the constant without the docs would
1712        // otherwise drift silently.
1713        assert_eq!(MAX_ERROR_BODY_BYTES, 1024);
1714    }
1715
1716    #[test]
1717    fn flex_poll_policy_default_is_the_documented_budget() {
1718        // Asserted against literals, not against the constants the impl already reads: comparing
1719        // `Default::default()` to a struct built from those same constants is a tautology that
1720        // holds for any value, and — since `POLL_INITIAL_DELAY` and `POLL_INTERVAL` are both 5s —
1721        // would not even catch the two being transposed. These literals are the budget the public
1722        // rustdoc promises (~65s total), so a change here is a deliberate, visible contract change.
1723        assert_eq!(
1724            FlexPollPolicy::default(),
1725            FlexPollPolicy {
1726                initial_delay: Duration::from_secs(5),
1727                interval: Duration::from_secs(5),
1728                max_attempts: 12,
1729            }
1730        );
1731    }
1732
1733    #[test]
1734    fn sanitize_error_body_scrubs_reqwests_wire_encoded_token() {
1735        // A token with characters that encode differently on the wire (space -> `+`; `+ / ~` -> `%..`).
1736        // If a proxy reflects the *encoded* request line into its error page, the raw-token scrub
1737        // alone would miss it. This pins two properties: (1) `byte_serialize` produces exactly what
1738        // reqwest's `.query()` puts on the wire — so the scrub can't silently drift from the real
1739        // encoding if reqwest/url change — and (2) that encoded form is redacted from the body.
1740        let token = "abc def+g/h~i";
1741
1742        // What reqwest actually serialises onto the query string (`build()` only — nothing is sent).
1743        let request = reqwest::Client::new()
1744            .get("https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement")
1745            .query(&[("t", token), ("q", "1234567890"), ("v", FLEX_VERSION)])
1746            .build()
1747            .expect("request builds without sending");
1748        let wire_query = request.url().query().expect("query present");
1749
1750        let wire_form: String = url::form_urlencoded::byte_serialize(token.as_bytes()).collect();
1751        assert_ne!(
1752            wire_form, token,
1753            "test token must actually encode to something different, else it proves nothing"
1754        );
1755        assert!(
1756            wire_query.contains(&format!("t={wire_form}")),
1757            "byte_serialize must match reqwest's actual query encoding, else the wire-form scrub \
1758             silently stops matching; query = {wire_query}, wire_form = {wire_form}"
1759        );
1760
1761        // A proxy that echoes the received request line into its error page reflects the encoded form.
1762        let reflected = format!("502 Bad Gateway while proxying GET /GetStatement?{wire_query}");
1763        let scrubbed = sanitize_error_body(&reflected, token);
1764        assert!(
1765            !scrubbed.contains(&wire_form),
1766            "wire-encoded token must be redacted, got: {scrubbed}"
1767        );
1768        assert!(scrubbed.contains("[REDACTED]"));
1769    }
1770
1771    #[tokio::test]
1772    async fn poll_until_ready_zero_max_attempts_times_out_without_polling() {
1773        // `max_attempts == 0` is the documented "no polling requested" case: it must return
1774        // immediately with `PollTimeout { attempts: 0 }` and issue zero GetStatement calls (and skip
1775        // the initial delay). Zero-duration policy keeps the test instant.
1776        let policy = FlexPollPolicy {
1777            initial_delay: Duration::ZERO,
1778            interval: Duration::ZERO,
1779            max_attempts: 0,
1780        };
1781        let calls = std::cell::Cell::new(0u32);
1782        let result = poll_until_ready(&policy, "tok", async || {
1783            calls.set(calls.get() + 1);
1784            Ok((reqwest::StatusCode::OK, GET_READY.to_owned()))
1785        })
1786        .await;
1787        assert_eq!(calls.get(), 0, "max_attempts == 0 must not issue any poll");
1788        assert!(matches!(
1789            result,
1790            Err(IbkrFlexError::PollTimeout { attempts: 0 })
1791        ));
1792    }
1793
1794    #[tokio::test]
1795    async fn poll_until_ready_returns_as_soon_as_statement_is_ready() {
1796        // The loop must stop at the first Ready response, not run the full budget.
1797        let policy = FlexPollPolicy {
1798            initial_delay: Duration::ZERO,
1799            interval: Duration::ZERO,
1800            max_attempts: 5,
1801        };
1802        let calls = std::cell::Cell::new(0u32);
1803        let result = poll_until_ready(&policy, "tok", async || {
1804            calls.set(calls.get() + 1);
1805            // In progress on the first poll, ready on the second.
1806            let body = if calls.get() < 2 {
1807                GET_IN_PROGRESS
1808            } else {
1809                GET_READY
1810            };
1811            Ok((reqwest::StatusCode::OK, body.to_owned()))
1812        })
1813        .await;
1814        assert_eq!(calls.get(), 2, "must stop polling once Ready is seen");
1815        assert_eq!(result.expect("ready"), GET_READY);
1816    }
1817
1818    #[tokio::test]
1819    async fn poll_until_ready_exhausts_budget_and_reports_attempt_count() {
1820        // Every poll reports in-progress: the loop must run exactly `max_attempts` times — proving
1821        // the `1..=max_attempts` bound and the `attempt < max_attempts` sleep guard don't over- or
1822        // under-run — and surface that count in `PollTimeout`.
1823        let policy = FlexPollPolicy {
1824            initial_delay: Duration::ZERO,
1825            interval: Duration::ZERO,
1826            max_attempts: 3,
1827        };
1828        let calls = std::cell::Cell::new(0u32);
1829        let result = poll_until_ready(&policy, "tok", async || {
1830            calls.set(calls.get() + 1);
1831            Ok((reqwest::StatusCode::OK, GET_IN_PROGRESS.to_owned()))
1832        })
1833        .await;
1834        assert_eq!(calls.get(), 3, "must poll exactly max_attempts times");
1835        assert!(matches!(
1836            result,
1837            Err(IbkrFlexError::PollTimeout { attempts: 3 })
1838        ));
1839    }
1840
1841    #[tokio::test(start_paused = true)]
1842    async fn poll_until_ready_sleeps_initial_delay_first_then_interval_between_polls() {
1843        // The zero-duration tests above prove call counts and outcomes but not the *timing* wiring:
1844        // that `initial_delay` is awaited **before** the first poll and `interval` between subsequent
1845        // polls. Under `start_paused`, Tokio advances virtual time whenever the runtime is idle on a
1846        // timer, so each `sleep` completes deterministically and we can record the clock at every poll
1847        // without real sleeping.
1848        let policy = FlexPollPolicy {
1849            initial_delay: Duration::from_secs(5),
1850            interval: Duration::from_secs(2),
1851            max_attempts: 3,
1852        };
1853        let start = tokio::time::Instant::now();
1854        let elapsed_at_poll = std::cell::RefCell::new(Vec::<Duration>::new());
1855        let result = poll_until_ready(&policy, "tok", async || {
1856            elapsed_at_poll.borrow_mut().push(start.elapsed());
1857            // Always in-progress so the loop runs the full budget, exercising every inter-poll sleep.
1858            Ok((reqwest::StatusCode::OK, GET_IN_PROGRESS.to_owned()))
1859        })
1860        .await;
1861        assert!(matches!(
1862            result,
1863            Err(IbkrFlexError::PollTimeout { attempts: 3 })
1864        ));
1865
1866        // Poll 1 at t=5s proves `initial_delay` elapsed *before* the first poll (not t=0); polls 2 and
1867        // 3 one `interval` (2s) apart prove the between-poll sleep. A regression that polled before
1868        // sleeping, or that dropped a sleep, would shift these timestamps.
1869        assert_eq!(
1870            *elapsed_at_poll.borrow(),
1871            vec![
1872                Duration::from_secs(5),
1873                Duration::from_secs(7),
1874                Duration::from_secs(9),
1875            ],
1876        );
1877    }
1878}