Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion crates/fmp-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions crates/fmp-py/python/fmp/_native/errors/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
45 changes: 44 additions & 1 deletion crates/fmp-py/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! module for both), renders user-defined bases as `builtins.<Base>`, 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,
Expand Down Expand Up @@ -78,6 +78,27 @@ static ERROR_ATTRIBUTES: &[MemberInfo] = &[
default: None,
deprecated: None,
},
MemberInfo {
name: "retry_after",
r#type: <Option<f64> 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: <Option<BTreeMap<String, String>> 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: <Option<String> 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
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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(())
}

Expand All @@ -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::<FmpValidationError>()
.setattr("category", "validation")?;
Expand Down
12 changes: 9 additions & 3 deletions crates/fmp-py/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions crates/fmp-py/tests/test_error_headers.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions crates/fmp-py/tests/test_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 4 additions & 1 deletion crates/fmp-py/tests/typecheck_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions crates/libfmp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
15 changes: 11 additions & 4 deletions crates/libfmp/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};

Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -618,7 +619,8 @@ impl Client {
endpoint.id(),
response.status(),
Some(safe_body(response.body(), redactor)),
),
)
.with_headers(safe_headers(&response, redactor)),
});
}

Expand Down Expand Up @@ -1138,7 +1140,12 @@ fn status_error<R>(
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 {
Expand Down
Loading
Loading