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