diff --git a/crates/fmp-py/README.md b/crates/fmp-py/README.md index 1a2d9ec..f131356 100644 --- a/crates/fmp-py/README.md +++ b/crates/fmp-py/README.md @@ -152,7 +152,7 @@ package root): | Exception | Raised when | |-----------|-------------| -| `FmpError` | base class; carries `category`, `endpoint`, `status`, `body`, `body_truncated`, `decode_path`, `decode_kind` | +| `FmpError` | base class; carries `category`, `endpoint`, `status`, `body`, `body_truncated`, `decode_path`, `decode_kind`, `retry_after`, `headers`, `proxy_error` | | `FmpConfigError` | the client cannot be built (missing key, bad URL, insecure auth) | | `FmpValidationError` | an argument is rejected before the request | | `FmpTransportError` | the request never produced a response | @@ -173,6 +173,14 @@ except FmpStatusError as error: Bodies attached to errors are redacted before they reach Python: an echoed `apikey` query value shows as `[REDACTED]`. +A status error also keeps an allowlisted set of response headers, useful +behind a rate-limiting proxy: `headers` is a dict of the lowercase +`retry-after`, `x-proxy-*` and `x-ratelimit-*` headers (or `None`), +`retry_after` is the delay in seconds as a float (delta-seconds or HTTP-date, +a past date is `0.0`), and `proxy_error` is the proxy's `X-Proxy-Error` +reason, such as `"rate_limited"`. No other header (`Set-Cookie`, +`Authorization`, ...) is ever stored, and none appears in `str(error)`. + ## Closing a client `close()` releases the client's pooled HTTP connections. Use the client as a diff --git a/crates/fmp-py/python/fmp/_native/errors/__init__.pyi b/crates/fmp-py/python/fmp/_native/errors/__init__.pyi index bf31679..e1f5ab6 100644 --- a/crates/fmp-py/python/fmp/_native/errors/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/errors/__init__.pyi @@ -76,6 +76,24 @@ class FmpError(builtins.Exception): Why a JSON response failed to decode: `"syntax"`, `"null"`, `"missing_member"`, `"wrong_type"`, or `"invalid_value"`. """ + @property + def retry_after(self) -> typing.Optional[builtins.float]: + r""" + Seconds to wait before retrying, from `Retry-After` (delta-seconds or HTTP-date; a past date is `0.0`), when sent. + """ + + @property + def headers(self) -> typing.Optional[builtins.dict[builtins.str, builtins.str]]: + r""" + The allowlisted response headers (`retry-after`, `x-proxy-*`, `x-ratelimit-*`), lowercase and redacted; `None` when none were sent. + """ + + @property + def proxy_error(self) -> typing.Optional[builtins.str]: + r""" + The `X-Proxy-Error` reason a proxy such as valet sent, for example `"rate_limited"`. + """ + class FmpStatusError(FmpError): r""" diff --git a/crates/fmp-py/src/errors.rs b/crates/fmp-py/src/errors.rs index 6ab1b92..7acb368 100644 --- a/crates/fmp-py/src/errors.rs +++ b/crates/fmp-py/src/errors.rs @@ -9,7 +9,7 @@ //! module for both), renders user-defined bases as `builtins.`, and has //! no way to declare the structured attributes, hence the local macro. -use std::fmt; +use std::{collections::BTreeMap, fmt}; use pyo3::{ create_exception, @@ -78,6 +78,27 @@ static ERROR_ATTRIBUTES: &[MemberInfo] = &[ default: None, deprecated: None, }, + MemberInfo { + name: "retry_after", + r#type: as PyStubType>::type_output, + doc: "Seconds to wait before retrying, from `Retry-After` (delta-seconds or HTTP-date; a past date is `0.0`), when sent.", + default: None, + deprecated: None, + }, + MemberInfo { + name: "headers", + r#type: > as PyStubType>::type_output, + doc: "The allowlisted response headers (`retry-after`, `x-proxy-*`, `x-ratelimit-*`), lowercase and redacted; `None` when none were sent.", + default: None, + deprecated: None, + }, + MemberInfo { + name: "proxy_error", + r#type: as PyStubType>::type_output, + doc: "The `X-Proxy-Error` reason a proxy such as valet sent, for example `\"rate_limited\"`.", + default: None, + deprecated: None, + }, ]; /// Declares one exception type: the pyo3 type object under `fmp.errors` plus @@ -204,6 +225,9 @@ fn set_validation_attributes(exception: &Bound<'_, PyBaseException>) -> PyResult exception.setattr("body_truncated", py.None())?; exception.setattr("decode_path", py.None())?; exception.setattr("decode_kind", py.None())?; + exception.setattr("retry_after", py.None())?; + exception.setattr("headers", py.None())?; + exception.setattr("proxy_error", py.None())?; Ok(()) } @@ -226,6 +250,22 @@ fn set_error_attributes( .decode_kind() .map(libfmp::error::DecodeErrorKind::as_str), )?; + exception.setattr( + "retry_after", + error.retry_after().map(|delay| delay.as_secs_f64()), + )?; + let headers = error.headers(); + exception.setattr( + "headers", + (!headers.is_empty()).then(|| { + let mut retained = BTreeMap::new(); + for (name, value) in headers.iter() { + retained.entry(name).or_insert(value); + } + retained + }), + )?; + exception.setattr("proxy_error", error.proxy_error())?; Ok(()) } @@ -244,6 +284,9 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { base.setattr("body_truncated", py.None())?; base.setattr("decode_path", py.None())?; base.setattr("decode_kind", py.None())?; + base.setattr("retry_after", py.None())?; + base.setattr("headers", py.None())?; + base.setattr("proxy_error", py.None())?; py.get_type::() .setattr("category", "validation")?; diff --git a/crates/fmp-py/tests/conftest.py b/crates/fmp-py/tests/conftest.py index eb055c8..73ef04d 100644 --- a/crates/fmp-py/tests/conftest.py +++ b/crates/fmp-py/tests/conftest.py @@ -96,15 +96,16 @@ def _errors_namespace() -> SimpleNamespace: @dataclass(frozen=True) class Route: - """One canned response: HTTP status, body, and content type. + """One canned response: HTTP status, body, content type, and extra headers. ``body`` is sent verbatim when it is ``bytes``; any other value is JSON - encoded. + encoded. ``headers`` are sent in order after the content type. """ body: Any = field(default_factory=list) status: int = 200 content_type: str = JSON_CONTENT_TYPE + headers: tuple[tuple[str, str], ...] = () def payload(self) -> bytes: """Return the encoded response body.""" @@ -157,9 +158,12 @@ def route( *, status: int = 200, content_type: str = JSON_CONTENT_TYPE, + headers: tuple[tuple[str, str], ...] = (), ) -> Route: """Register the response for ``path`` and return the stored route.""" - stored = Route(body=[] if body is None else body, status=status, content_type=content_type) + stored = Route( + body=[] if body is None else body, status=status, content_type=content_type, headers=headers + ) self.routes[path] = stored return stored @@ -206,6 +210,8 @@ def do_GET(self) -> None: payload = route.payload() self.send_response(route.status) self.send_header("Content-Type", route.content_type) + for name, value in route.headers: + self.send_header(name, value) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) diff --git a/crates/fmp-py/tests/test_error_headers.py b/crates/fmp-py/tests/test_error_headers.py new file mode 100644 index 0000000..8e12e01 --- /dev/null +++ b/crates/fmp-py/tests/test_error_headers.py @@ -0,0 +1,86 @@ +"""Issue #416: status errors keep only the allowlisted response headers.""" + +import time +from email.utils import formatdate +from types import SimpleNamespace +from typing import Any + +import pytest +from conftest import FixtureServer + +QUOTE_SHORT_PATH = "/quote-short" + +VALET_HEADERS = ( + ("Retry-After", "12"), + ("X-Proxy-Key-Id", "vk_123"), + ("X-Proxy-Error", "rate_limited"), + ("Set-Cookie", "session=cookie-secret"), + ("Authorization", "Bearer header-secret"), + ("apikey", "query-secret"), + ("X-Unrelated", "unrelated-value"), + ("X-Proxy-Burst-Remaining", "0"), + ("X-RateLimit-Remaining", "0"), +) + +ALLOWLISTED = { + "retry-after": "12", + "x-proxy-key-id": "vk_123", + "x-proxy-error": "rate_limited", + "x-proxy-burst-remaining": "0", + "x-ratelimit-remaining": "0", +} + + +def raise_status(client: Any, errors: SimpleNamespace) -> Any: + with pytest.raises(errors.FmpStatusError) as raised: + client.quote.short("AAPL") + return raised.value + + +def test_rate_limited_status_error_exposes_retry_after_and_proxy_error( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """A 429 with ``Retry-After: 12`` reports 12 seconds and the proxy reason.""" + fixture_server.route(QUOTE_SHORT_PATH, b"slow down", status=429, content_type="text/plain", headers=VALET_HEADERS) + error = raise_status(client, errors) + assert error.status == 429 + assert error.retry_after == 12.0 + assert error.proxy_error == "rate_limited" + assert error.headers == ALLOWLISTED + assert str(error) == "provider returned HTTP status 429 (endpoint: quote-short): slow down" + rendered = f"{error!s} {error!r} {error.headers}" + for secret in ("cookie-secret", "header-secret", "query-secret", "unrelated-value"): + assert secret not in rendered + + +def test_provider_message_keeps_allowlisted_headers( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """A 200 provider message keeps the same allowlisted headers.""" + fixture_server.route(QUOTE_SHORT_PATH, b"Invalid name", headers=VALET_HEADERS) + error = raise_status(client, errors) + assert error.status == 200 + assert error.headers == ALLOWLISTED + assert error.retry_after == 12.0 + + +def test_http_date_retry_after_is_relative_to_now( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """An HTTP-date ``Retry-After`` becomes the seconds left until that date.""" + + retry_at = formatdate(time.time() + 120, usegmt=True) + fixture_server.route(QUOTE_SHORT_PATH, b"", status=503, headers=(("Retry-After", retry_at),)) + error = raise_status(client, errors) + assert error.retry_after is not None + assert 100.0 <= error.retry_after <= 121.0 + assert error.proxy_error is None + + +def test_status_error_without_allowlisted_headers_reports_none( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """Only non-allowlisted headers leave every header attribute ``None``.""" + fixture_server.route(QUOTE_SHORT_PATH, b"", status=500, headers=(("Set-Cookie", "a=b"),)) + error = raise_status(client, errors) + assert (error.retry_after, error.headers, error.proxy_error) == (None, None, None) diff --git a/crates/fmp-py/tests/test_import.py b/crates/fmp-py/tests/test_import.py index b3232b1..5f813b2 100644 --- a/crates/fmp-py/tests/test_import.py +++ b/crates/fmp-py/tests/test_import.py @@ -246,3 +246,4 @@ def test_exception_attributes_default_to_none(errors: SimpleNamespace, name: str assert exception.category == category assert (exception.endpoint, exception.status, exception.body, exception.body_truncated) == (None, None, None, None) assert (exception.decode_path, exception.decode_kind) == (None, None) + assert (exception.retry_after, exception.headers, exception.proxy_error) == (None, None, None) diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 114812d..587bbc7 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -677,7 +677,10 @@ def check_error_contract(client: FmpClient) -> None: endpoint: str | None = error.endpoint body: str | None = error.body body_truncated: bool | None = error.body_truncated - _ = (status, endpoint, body, body_truncated) + retry_after: float | None = error.retry_after + headers: dict[str, str] | None = error.headers + proxy_error: str | None = error.proxy_error + _ = (status, endpoint, body, body_truncated, retry_after, headers, proxy_error) except FmpValidationError as error: category: str | None = error.category _ = category diff --git a/crates/libfmp/README.md b/crates/libfmp/README.md index 5abe590..cfe1154 100644 --- a/crates/libfmp/README.md +++ b/crates/libfmp/README.md @@ -93,4 +93,11 @@ let rows = client.quote_short(Ticker::new("AAPL")?).await?; # } ``` +A status error keeps an allowlisted set of response headers: `Retry-After`, +`X-Proxy-*` and `X-RateLimit-*`. `err.retry_after()` returns the delay as a +`Duration` (delta-seconds or HTTP-date, a past date is zero), +`err.proxy_error()` returns the proxy's `X-Proxy-Error` reason, and +`err.headers()` lists every retained header. No other header is stored, and +headers never appear in the error's `Display` text. + Rust 1.96 or newer is supported. The repository pins Rust 1.96.0 for development and release validation. This project is available under the [MIT License](LICENSE). diff --git a/crates/libfmp/src/client.rs b/crates/libfmp/src/client.rs index 1231f77..2c460ba 100644 --- a/crates/libfmp/src/client.rs +++ b/crates/libfmp/src/client.rs @@ -28,7 +28,7 @@ use crate::{ endpoints::{ DecodeFailure, ExpectedContentType, QueryEncoder, ResponseMetadata, is_provider_message, }, - error::{ConfigurationErrorKind, Error, Redactor, SafeBody, SecretString}, + error::{ConfigurationErrorKind, Error, Redactor, SafeBody, SafeHeaders, SecretString}, transport::{HttpExecutor, PreparedRequest, ReqwestExecutor, TransportResponse}, }; @@ -580,7 +580,8 @@ impl Client { endpoint.id(), response.status(), Some(safe_body(response.body(), redactor)), - )); + ) + .with_headers(safe_headers(&response, redactor))); } return Err(Error::decode( Some(endpoint.id()), @@ -618,7 +619,8 @@ impl Client { endpoint.id(), response.status(), Some(safe_body(response.body(), redactor)), - ), + ) + .with_headers(safe_headers(&response, redactor)), }); } @@ -1138,7 +1140,12 @@ fn status_error( endpoint, response.status(), Some(safe_body(response.body(), redactor)), - )) + ) + .with_headers(safe_headers(&response, redactor))) +} + +fn safe_headers(response: &TransportResponse, redactor: &Redactor) -> SafeHeaders { + SafeHeaders::from_header_map(response.headers(), redactor) } fn safe_body(body: &[u8], redactor: &Redactor) -> SafeBody { diff --git a/crates/libfmp/src/error.rs b/crates/libfmp/src/error.rs index dfd0f08..efe7610 100644 --- a/crates/libfmp/src/error.rs +++ b/crates/libfmp/src/error.rs @@ -1,7 +1,15 @@ //! Stable, transport-independent error and diagnostic safety contracts. -use std::{borrow::Cow, collections::BTreeSet, error::Error as StdError, fmt}; - +use std::{ + borrow::Cow, + collections::BTreeSet, + error::Error as StdError, + fmt, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use chrono::{DateTime, NaiveDateTime}; +use http::HeaderMap; use serde::{Deserialize, Deserializer}; use crate::types::{EmptyTickerList, InvalidDateRange, InvalidTemporalValue, StringValueError}; @@ -12,6 +20,13 @@ pub const REDACTED: &str = "[REDACTED]"; /// Maximum UTF-8 byte length retained from a provider response body. pub const MAX_SAFE_BODY_BYTES: usize = 4096; +/// Maximum number of response headers retained by [`SafeHeaders`]. +pub const MAX_SAFE_HEADERS: usize = 16; + +/// Maximum UTF-8 byte length of a response header value retained by +/// [`SafeHeaders`]; longer values are dropped, never truncated. +pub const MAX_SAFE_HEADER_VALUE_BYTES: usize = 256; + /// Why a custom secret header or query name was rejected. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -494,6 +509,104 @@ impl fmt::Debug for SafeBody { } } +/// Reports whether a response header may be retained for callers. +/// +/// This is the single allowlist shared by every place that exposes response +/// headers: `retry-after`, plus every name starting with `x-proxy-` or +/// `x-ratelimit-`, compared case-insensitively. Everything else, including +/// `set-cookie`, `authorization` and `apikey`, is never retained. +pub fn is_retained_header_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name == "retry-after" || name.starts_with("x-proxy-") || name.starts_with("x-ratelimit-") +} + +/// The allowlisted, redacted and bounded response headers kept for callers. +/// +/// Only names accepted by [`is_retained_header_name`] are kept, lowercased +/// and in response order. Values that are not visible ASCII or exceed +/// [`MAX_SAFE_HEADER_VALUE_BYTES`] are dropped, at most [`MAX_SAFE_HEADERS`] +/// entries are kept, and every value passes through the [`Redactor`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SafeHeaders { + entries: Box<[(Box, Box)]>, +} + +impl SafeHeaders { + /// Keeps the allowlisted headers of a response, redacted and bounded. + pub fn from_header_map(headers: &HeaderMap, redactor: &Redactor) -> Self { + let entries = headers + .iter() + .filter(|(name, _)| is_retained_header_name(name.as_str())) + .filter_map(|(name, value)| { + let value = redactor.redact_header(name.as_str(), value.to_str().ok()?); + (value.len() <= MAX_SAFE_HEADER_VALUE_BYTES) + .then(|| (name.as_str().into(), value.into_boxed_str())) + }) + .take(MAX_SAFE_HEADERS) + .collect(); + Self { entries } + } + + /// Returns the first retained value for a name, compared case-insensitively. + pub fn get(&self, name: &str) -> Option<&str> { + self.iter() + .find(|(retained, _)| retained.eq_ignore_ascii_case(name)) + .map(|(_, value)| value) + } + + /// Iterates the retained `(lowercase name, value)` pairs in response order. + pub fn iter(&self) -> impl Iterator { + self.entries + .iter() + .map(|(name, value)| (name.as_ref(), value.as_ref())) + } + + /// Returns the number of retained headers. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Reports whether no header was retained. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Returns the `Retry-After` delay relative to now. + /// + /// See [`SafeHeaders::retry_after_at`]. + pub fn retry_after(&self) -> Option { + self.retry_after_at(SystemTime::now()) + } + + /// Returns the `Retry-After` delay relative to `now`. + /// + /// Accepts delta-seconds and every HTTP-date form of RFC 9110. A date at + /// or before `now` yields `Some(Duration::ZERO)`, meaning retry at once; + /// an absent or unparsable value yields `None`. + pub fn retry_after_at(&self, now: SystemTime) -> Option { + let value = self.get("retry-after")?.trim(); + if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) { + return Some(Duration::from_secs(value.parse().unwrap_or(u64::MAX))); + } + let target = parse_http_date(value)?; + let now = now.duration_since(UNIX_EPOCH).ok()?; + Some(target.saturating_sub(now)) + } +} + +/// Parses an IMF-fixdate, RFC 850 or asctime HTTP-date as time since the epoch. +fn parse_http_date(value: &str) -> Option { + let seconds = DateTime::parse_from_rfc2822(value) + .map(|date| date.timestamp()) + .or_else(|_| { + NaiveDateTime::parse_from_str(value, "%A, %d-%b-%y %H:%M:%S GMT") + .or_else(|_| NaiveDateTime::parse_from_str(value, "%a %b %e %H:%M:%S %Y")) + .map(|date| date.and_utc().timestamp()) + }) + .ok()?; + Some(Duration::from_secs(u64::try_from(seconds).unwrap_or(0))) +} + /// The stable error value returned by the Rust client. /// /// Equality compares every retained field, so tests and callers can match a @@ -512,6 +625,7 @@ pub struct Error { configuration_kind: Option, decode_path: Option>, decode_kind: Option, + headers: SafeHeaders, } impl Error { @@ -603,9 +717,16 @@ impl Error { configuration_kind: None, decode_path: None, decode_kind: None, + headers: SafeHeaders::default(), } } + /// Attaches the allowlisted response headers of a status error. + pub(crate) fn with_headers(mut self, headers: SafeHeaders) -> Self { + self.headers = headers; + self + } + /// Attaches where and why a JSON response failed to decode. /// /// `path` names members and array indexes only, never a member value. @@ -668,6 +789,28 @@ impl Error { pub fn decode_kind(&self) -> Option { self.decode_kind } + + /// Returns the allowlisted response headers, empty when none were kept. + /// + /// Status errors keep `Retry-After`, `X-Proxy-*` and `X-RateLimit-*` + /// (see [`is_retained_header_name`]); no other error carries headers. + /// Headers never appear in the [`Display`](fmt::Display) output. + pub fn headers(&self) -> &SafeHeaders { + &self.headers + } + + /// Returns how long to wait before retrying, from `Retry-After`. + /// + /// See [`SafeHeaders::retry_after_at`] for the accepted forms; a date in + /// the past yields `Some(Duration::ZERO)`. + pub fn retry_after(&self) -> Option { + self.headers.retry_after() + } + + /// Returns the `X-Proxy-Error` reason a proxy such as valet sent. + pub fn proxy_error(&self) -> Option<&str> { + self.headers.get("x-proxy-error") + } } impl fmt::Display for Error { diff --git a/crates/libfmp/tests/error_headers.rs b/crates/libfmp/tests/error_headers.rs new file mode 100644 index 0000000..b540363 --- /dev/null +++ b/crates/libfmp/tests/error_headers.rs @@ -0,0 +1,131 @@ +#[allow(dead_code)] // Only the fixture executor is used here. +mod support; + +use std::{sync::Arc, time::Duration}; + +use libfmp::{ + Client, + config::Authentication, + endpoints::economics::EconomicIndicatorsQuery, + error::ErrorCategory, + http::{HeaderMap, HeaderName, HeaderValue, header::CONTENT_TYPE}, + query::EconomicIndicator, + transport::TransportResponse, +}; + +use support::FixtureExecutor; + +const VALET_HEADERS: &[(&str, &str)] = &[ + ("Retry-After", "12"), + ("X-Proxy-Key-Id", "vk_123"), + ("X-Proxy-Error", "rate_limited"), + ("Set-Cookie", "session=cookie-secret"), + ("Authorization", "Bearer header-secret"), + ("apikey", "query-secret"), + ("Cookie", "cookie=jar-secret"), + ("X-Unrelated", "unrelated-value"), + ("X-Proxy-Burst-Remaining", "0"), + ("X-Proxy-Daily-Remaining", "250"), + ("X-Proxy-Bytes-30d", "1048576"), + ("X-RateLimit-Remaining", "0"), +]; + +fn header_map(pairs: &[(&str, &str)]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.append( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + headers +} + +async fn economic_indicators(response: TransportResponse) -> libfmp::Error { + let executor = Arc::new(FixtureExecutor::new([response])); + let client = Client::builder() + .base_url("https://proxy.example/router") + .path_prefix("stable") + .authentication(Authentication::None) + .executor(executor) + .build() + .unwrap(); + client + .economic_indicators(EconomicIndicatorsQuery::new(EconomicIndicator::Gdp)) + .await + .unwrap_err() +} + +fn assert_only_allowlisted(error: &libfmp::Error) { + let kept: Vec<_> = error.headers().iter().collect(); + assert_eq!( + kept, + [ + ("retry-after", "12"), + ("x-proxy-key-id", "vk_123"), + ("x-proxy-error", "rate_limited"), + ("x-proxy-burst-remaining", "0"), + ("x-proxy-daily-remaining", "250"), + ("x-proxy-bytes-30d", "1048576"), + ("x-ratelimit-remaining", "0"), + ] + ); + for name in [ + "set-cookie", + "authorization", + "apikey", + "cookie", + "x-unrelated", + ] { + assert_eq!(error.headers().get(name), None, "{name}"); + } + let debug = format!("{error:?}"); + for secret in [ + "cookie-secret", + "header-secret", + "query-secret", + "jar-secret", + "unrelated-value", + ] { + assert!(!debug.contains(secret), "{secret}"); + } +} + +#[tokio::test] +async fn status_error_keeps_retry_after_and_proxy_headers_only() { + let response = TransportResponse::new(429, header_map(VALET_HEADERS), "slow down"); + let error = economic_indicators(response).await; + + assert_eq!(error.category(), ErrorCategory::Status); + assert_eq!(error.status_code(), Some(429)); + assert_eq!(error.retry_after(), Some(Duration::from_secs(12))); + assert_eq!(error.proxy_error(), Some("rate_limited")); + assert_eq!(error.headers().get("X-PROXY-KEY-ID"), Some("vk_123")); + assert_only_allowlisted(&error); + assert_eq!( + error.to_string(), + "provider returned HTTP status 429 (endpoint: economic-indicators): slow down" + ); +} + +#[tokio::test] +async fn provider_message_keeps_allowlisted_headers() { + let mut headers = header_map(VALET_HEADERS); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let error = economic_indicators(TransportResponse::new(200, headers, "Invalid name")).await; + + assert_eq!(error.category(), ErrorCategory::Status); + assert_eq!(error.retry_after(), Some(Duration::from_secs(12))); + assert_eq!(error.proxy_error(), Some("rate_limited")); + assert_only_allowlisted(&error); +} + +#[tokio::test] +async fn status_error_without_allowlisted_headers_is_empty() { + let response = TransportResponse::new(500, header_map(&[("Set-Cookie", "a=b")]), ""); + let error = economic_indicators(response).await; + + assert!(error.headers().is_empty()); + assert_eq!(error.retry_after(), None); + assert_eq!(error.proxy_error(), None); +} diff --git a/crates/libfmp/tests/safe_headers.rs b/crates/libfmp/tests/safe_headers.rs new file mode 100644 index 0000000..36d7f2c --- /dev/null +++ b/crates/libfmp/tests/safe_headers.rs @@ -0,0 +1,97 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use libfmp::{ + error::{ + MAX_SAFE_HEADER_VALUE_BYTES, MAX_SAFE_HEADERS, Redactor, SafeHeaders, SecretString, + is_retained_header_name, + }, + http::{HeaderMap, HeaderName, HeaderValue}, +}; + +fn header_map(pairs: &[(&str, &str)]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.append( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + headers +} + +#[test] +fn allowlist_is_case_insensitive_and_closed() { + for name in [ + "Retry-After", + "X-Proxy-Error", + "x-proxy-anything", + "X-RateLimit-Reset", + ] { + assert!(is_retained_header_name(name), "{name}"); + } + for name in [ + "set-cookie", + "authorization", + "apikey", + "cookie", + "x-proxy", + "retry-after-ms", + ] { + assert!(!is_retained_header_name(name), "{name}"); + } +} + +fn retry_after(value: &str, now: SystemTime) -> Option { + SafeHeaders::from_header_map(&header_map(&[("Retry-After", value)]), &Redactor::new()) + .retry_after_at(now) +} + +#[test] +fn retry_after_accepts_every_http_date_form() { + let now = UNIX_EPOCH + Duration::from_secs(784_111_717); + for date in [ + "Sun, 06 Nov 1994 08:49:37 GMT", + "Sunday, 06-Nov-94 08:49:37 GMT", + "Sun Nov 6 08:49:37 1994", + ] { + assert_eq!( + retry_after(date, now), + Some(Duration::from_secs(60)), + "{date}" + ); + } +} + +#[test] +fn retry_after_in_the_past_is_zero_and_garbage_is_none() { + let now = UNIX_EPOCH + Duration::from_secs(900_000_000); + assert_eq!( + retry_after("Sun, 06 Nov 1994 08:49:37 GMT", now), + Some(Duration::ZERO) + ); + assert_eq!(retry_after(" 7 ", now), Some(Duration::from_secs(7))); + for value in ["soon", "-5", "1.5", ""] { + assert_eq!(retry_after(value, now), None, "{value:?}"); + } +} + +#[test] +fn retained_headers_are_redacted_and_bounded() { + let mut redactor = Redactor::new(); + redactor.add_secret(&SecretString::new("vk_live_secret".to_owned())); + let mut pairs = vec![ + ("X-Proxy-Key-Id", "vk_live_secret".to_owned()), + ("X-Proxy-Long", "x".repeat(MAX_SAFE_HEADER_VALUE_BYTES + 1)), + ]; + pairs.extend((0..MAX_SAFE_HEADERS + 4).map(|index| ("X-Proxy-Count", index.to_string()))); + let pairs: Vec<_> = pairs + .iter() + .map(|(name, value)| (*name, value.as_str())) + .collect(); + + let headers = SafeHeaders::from_header_map(&header_map(&pairs), &redactor); + + assert_eq!(headers.len(), MAX_SAFE_HEADERS); + assert_eq!(headers.get("x-proxy-key-id"), Some("[REDACTED]")); + assert_eq!(headers.get("x-proxy-long"), None); +} diff --git a/sdk/go/README.md b/sdk/go/README.md index 3dacbb4..72dd368 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -195,7 +195,11 @@ when FMP answers a success status with its own error message (the plain-text of the documented payload. A `Decode` error also carries `Path`, the JSON pointer of the failing member (`/37/beta`), and `DecodeKind`, the coarse reason (`DecodeKindNull`, `DecodeKindWrongType`, ...); neither ever includes -the member value. The client never retries, wraps every +the member value. A `Status` error also keeps `Headers`, only the allowlisted +`Retry-After`, `X-Proxy-*` and `X-RateLimit-*` response headers (redacted, +nil when none arrived): `RetryAfter()` returns the delay (delta-seconds or +HTTP-date, a past date is zero) and `ProxyError()` the proxy's +`X-Proxy-Error` reason. The client never retries, wraps every transport failure before it escapes, and formats `Authentication`, `Client`, and `Error` without any secret value, so an error is safe to log. diff --git a/sdk/go/binary.go b/sdk/go/binary.go index 157a4a9..69e7c5b 100644 --- a/sdk/go/binary.go +++ b/sdk/go/binary.go @@ -68,7 +68,7 @@ func (c *Client) getBinary(ctx context.Context, endpointID, relativePath string, "successful response used an invalid content type", nil) case !matchesMediaType(contentType, expectedContentTypes) && !matchesSignature(resp.body, expectedContentTypes): if validJSONMediaType(contentType) && isProviderMessage(resp.body) { - return BinaryPayload{}, providerMessageError(endpointID, resp.status, c.safeBody(resp.body)) + return BinaryPayload{}, c.providerMessageError(endpointID, resp) } return BinaryPayload{}, decodeError(endpointID, resp.status, c.binaryErrorBody(resp.body, contentType), "successful response used an unexpected content type", nil) diff --git a/sdk/go/client.go b/sdk/go/client.go index a82d953..7b41e95 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -357,7 +357,7 @@ func (c *Client) getJSON(ctx context.Context, endpointID, relativePath string, q "successful response used an unexpected content type", nil) } if isProviderMessage(resp.body) { - return providerMessageError(endpointID, resp.status, c.safeBody(resp.body)) + return c.providerMessageError(endpointID, resp) } if err := json.Unmarshal(resp.body, out); err != nil { decoded := decodeError(endpointID, resp.status, c.safeBody(resp.body), diff --git a/sdk/go/errors.go b/sdk/go/errors.go index cbd640d..11bf27f 100644 --- a/sdk/go/errors.go +++ b/sdk/go/errors.go @@ -4,6 +4,7 @@ import ( "encoding/json/jsontext" "errors" "fmt" + "net/http" "slices" "sort" "strings" @@ -154,6 +155,11 @@ func (k DecodeKind) description() string { // pointer of the offending member, such as "/37/beta", and names array // indexes and member names only, never a member value. Path is "" for a // failure at the document root and on every non-decode error. +// +// Headers holds the allowlisted response headers of a Status error (see +// IsRetainedHeaderName), redacted and bounded; it is nil when none were +// received and never appears in the Error text. RetryAfter and ProxyError +// read it. type Error struct { Category ErrorCategory Message string @@ -163,6 +169,7 @@ type Error struct { ConfigurationKind ConfigurationKind Path string DecodeKind DecodeKind + Headers http.Header member string memberPath jsontext.Pointer memberKind DecodeKind diff --git a/sdk/go/headers.go b/sdk/go/headers.go new file mode 100644 index 0000000..d5e5384 --- /dev/null +++ b/sdk/go/headers.go @@ -0,0 +1,92 @@ +package fmp + +import ( + "maps" + "math" + "net/http" + "slices" + "strconv" + "strings" + "time" +) + +// MaxSafeHeaders is the largest number of response header values retained +// in an *Error. +const MaxSafeHeaders = 16 + +// MaxSafeHeaderValueBytes is the largest response header value retained in +// an *Error; longer values are dropped, never truncated. +const MaxSafeHeaderValueBytes = 256 + +// IsRetainedHeaderName reports whether a response header may be retained for +// callers. It is the single allowlist shared with the Rust and Python SDKs: +// Retry-After, plus every name starting with X-Proxy- or X-RateLimit-, +// compared case-insensitively. Everything else, including Set-Cookie, +// Authorization and apikey, is never retained. +func IsRetainedHeaderName(name string) bool { + name = asciiLower(name) + return name == "retry-after" || strings.HasPrefix(name, "x-proxy-") || + strings.HasPrefix(name, "x-ratelimit-") +} + +// retainedHeaders keeps the allowlisted headers of a response, redacted and +// bounded, or returns nil when none qualify. Values that are not visible +// ASCII or exceed MaxSafeHeaderValueBytes are dropped. +func retainedHeaders(header http.Header, redactor *Redactor) http.Header { + var kept http.Header + count := 0 + for _, name := range slices.Sorted(maps.Keys(header)) { + values := header[name] + if !IsRetainedHeaderName(name) { + continue + } + for _, value := range values { + if count == MaxSafeHeaders || !visibleHeaderText(value) { + continue + } + value = redactor.RedactHeader(name, value) + if len(value) > MaxSafeHeaderValueBytes { + continue + } + if kept == nil { + kept = make(http.Header) + } + kept.Add(name, value) + count++ + } + } + return kept +} + +// RetryAfter returns the Retry-After delay relative to now. It accepts +// delta-seconds and every HTTP-date form of RFC 9110; a date at or before now +// yields zero, meaning retry at once. The bool is false when the header is +// absent or unparsable. +func (e *Error) RetryAfter() (time.Duration, bool) { + return retryAfterAt(e.Headers.Get("Retry-After"), time.Now()) +} + +// ProxyError returns the X-Proxy-Error reason a proxy such as valet sent, or +// "" when there is none. +func (e *Error) ProxyError() string { + return e.Headers.Get("X-Proxy-Error") +} + +func retryAfterAt(value string, now time.Time) (time.Duration, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + if strings.Trim(value, "0123456789") == "" { + seconds, err := strconv.ParseInt(value, 10, 64) + if err != nil || seconds > int64(math.MaxInt64/time.Second) { + return math.MaxInt64, true + } + return time.Duration(seconds) * time.Second, true + } + date, err := http.ParseTime(value) + if err != nil { + return 0, false + } + return max(date.Sub(now), 0), true +} diff --git a/sdk/go/headers_test.go b/sdk/go/headers_test.go new file mode 100644 index 0000000..eeafebd --- /dev/null +++ b/sdk/go/headers_test.go @@ -0,0 +1,152 @@ +package fmp + +import ( + "fmt" + "net/http" + "reflect" + "strings" + "testing" + "time" +) + +var valetHeaders = [][2]string{ + {"Retry-After", "12"}, + {"X-Proxy-Key-Id", "vk_123"}, + {"X-Proxy-Error", "rate_limited"}, + {"Set-Cookie", "session=cookie-secret"}, + {"Authorization", "Bearer header-secret"}, + {"apikey", "query-secret"}, + {"Cookie", "cookie=jar-secret"}, + {"X-Unrelated", "unrelated-value"}, + {"X-Proxy-Burst-Remaining", "0"}, + {"X-RateLimit-Remaining", "0"}, +} + +func valetServer(t *testing.T, status int, contentType, body string) *Client { + t.Helper() + server, _ := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + for _, pair := range valetHeaders { + w.Header().Add(pair[0], pair[1]) + } + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + }) + return newClient(t, server) +} + +func assertOnlyAllowlisted(t *testing.T, typed *Error) { + t.Helper() + want := http.Header{ + "Retry-After": {"12"}, + "X-Proxy-Key-Id": {"vk_123"}, + "X-Proxy-Error": {"rate_limited"}, + "X-Proxy-Burst-Remaining": {"0"}, + "X-Ratelimit-Remaining": {"0"}, + } + if !reflect.DeepEqual(typed.Headers, want) { + t.Fatalf("Headers = %v, want %v", typed.Headers, want) + } + if delay, ok := typed.RetryAfter(); !ok || delay != 12*time.Second { + t.Fatalf("RetryAfter() = %v, %v, want 12s", delay, ok) + } + if got := typed.ProxyError(); got != "rate_limited" { + t.Fatalf("ProxyError() = %q", got) + } + dump := fmt.Sprintf("%+v %s", typed, typed.Error()) + for _, secret := range []string{"cookie-secret", "header-secret", "query-secret", "jar-secret", "unrelated-value"} { + if strings.Contains(dump, secret) { + t.Fatalf("error retained %q: %s", secret, dump) + } + } +} + +func TestStatusErrorKeepsRetryAfterAndProxyHeadersOnly(t *testing.T) { + t.Parallel() + _, err := probe(t, valetServer(t, http.StatusTooManyRequests, "", "slow down")) + typed := assertError(t, err, CategoryStatus, http.StatusTooManyRequests) + assertOnlyAllowlisted(t, typed) + if want := "provider returned HTTP status 429 (endpoint: endpoint-contract-test): slow down"; typed.Error() != want { + t.Fatalf("Error() = %q, want %q", typed.Error(), want) + } +} + +func TestProviderMessageKeepsAllowlistedHeaders(t *testing.T) { + t.Parallel() + _, err := probe(t, valetServer(t, http.StatusOK, "application/json", "Invalid name")) + assertOnlyAllowlisted(t, assertError(t, err, CategoryStatus, http.StatusOK)) +} + +func TestStatusErrorWithoutAllowlistedHeadersHasNilHeaders(t *testing.T) { + t.Parallel() + server, _ := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + _, err := probe(t, newClient(t, server)) + typed := assertError(t, err, CategoryStatus, http.StatusInternalServerError) + if _, ok := typed.RetryAfter(); typed.Headers != nil || ok || typed.ProxyError() != "" { + t.Fatalf("Headers = %v, want nil", typed.Headers) + } +} + +func TestIsRetainedHeaderNameMatchesTheRustAllowlist(t *testing.T) { + t.Parallel() + for _, name := range []string{"Retry-After", "X-Proxy-Error", "x-proxy-anything", "X-RateLimit-Reset"} { + if !IsRetainedHeaderName(name) { + t.Errorf("%s was not retained", name) + } + } + for _, name := range []string{"set-cookie", "authorization", "apikey", "cookie", "x-proxy", "retry-after-ms"} { + if IsRetainedHeaderName(name) { + t.Errorf("%s was retained", name) + } + } +} + +func TestRetryAfterAcceptsSecondsAndEveryHTTPDateForm(t *testing.T) { + t.Parallel() + now := time.Unix(784_111_717, 0) + cases := []struct { + value string + want time.Duration + ok bool + }{ + {"Sun, 06 Nov 1994 08:49:37 GMT", time.Minute, true}, + {"Sunday, 06-Nov-94 08:49:37 GMT", time.Minute, true}, + {"Sun Nov 6 08:49:37 1994", time.Minute, true}, + {"Sun, 06 Nov 1994 08:00:00 GMT", 0, true}, + {" 7 ", 7 * time.Second, true}, + {"soon", 0, false}, + {"-5", 0, false}, + {"1.5", 0, false}, + {"", 0, false}, + } + for _, tc := range cases { + if got, ok := retryAfterAt(tc.value, now); got != tc.want || ok != tc.ok { + t.Errorf("retryAfterAt(%q) = %v, %v, want %v, %v", tc.value, got, ok, tc.want, tc.ok) + } + } +} + +func TestRetainedHeadersAreRedactedAndBounded(t *testing.T) { + t.Parallel() + redactor := NewRedactor() + redactor.AddSecret("vk_live_secret") + header := http.Header{ + "X-Proxy-Key-Id": {"vk_live_secret"}, + "X-Proxy-Long": {strings.Repeat("x", MaxSafeHeaderValueBytes+1)}, + } + for i := range MaxSafeHeaders + 4 { + header.Add("X-Proxy-Zz", fmt.Sprint(i)) + } + kept := retainedHeaders(header, redactor) + total := 0 + for _, values := range kept { + total += len(values) + } + if total != MaxSafeHeaders || kept.Get("X-Proxy-Key-Id") != Redacted || kept.Get("X-Proxy-Long") != "" { + t.Fatalf("retainedHeaders = %v", kept) + } +} diff --git a/sdk/go/transport.go b/sdk/go/transport.go index 0fe598c..06780c7 100644 --- a/sdk/go/transport.go +++ b/sdk/go/transport.go @@ -239,7 +239,15 @@ func causeAddresses(err error) []string { } func (c *Client) statusError(endpointID string, resp *bufferedResponse) *Error { - return statusError(endpointID, resp.status, c.safeBody(resp.body)) + err := statusError(endpointID, resp.status, c.safeBody(resp.body)) + err.Headers = retainedHeaders(resp.header, c.redactor) + return err +} + +func (c *Client) providerMessageError(endpointID string, resp *bufferedResponse) *Error { + err := providerMessageError(endpointID, resp.status, c.safeBody(resp.body)) + err.Headers = retainedHeaders(resp.header, c.redactor) + return err } // applyQueryAuth strips any pair carrying the protected query name and