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
2 changes: 1 addition & 1 deletion crates/fmp-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ package root):
| `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 |
| `FmpStatusError` | the provider answered with a non-success status |
| `FmpStatusError` | the provider answered with a non-success status, or with a 200 whose body is its own error message (`Invalid name`, `{"Error Message": ...}`) |
| `FmpDecodeError` | the body could not be decoded into the documented shape; `decode_path` names the member (for example `[37].beta`) and `decode_kind` the reason, never the value |

```python
Expand Down
2 changes: 1 addition & 1 deletion crates/fmp-py/python/fmp/_native/errors/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class FmpError(builtins.Exception):

class FmpStatusError(FmpError):
r"""
The provider returned a non-success HTTP status.
The provider returned a non-success HTTP status, or a success status whose body is its own error message.
"""

...
Expand Down
2 changes: 1 addition & 1 deletion crates/fmp-py/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ fmp_exception!(
fmp_exception!(
FmpStatusError,
FmpError,
"The provider returned a non-success HTTP status."
"The provider returned a non-success HTTP status, or a success status whose body is its own error message."
);
fmp_exception!(
FmpDecodeError,
Expand Down
2 changes: 1 addition & 1 deletion crates/fmp-py/tests/test_calendar_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,6 @@ def test_status_error_carries_the_calendar_endpoint_id(

def test_decode_error_on_a_non_json_body(client: Any, fixture_server: FixtureServer, errors: SimpleNamespace) -> None:
"""A body that is not JSON maps to ``FmpDecodeError`` rather than a bare exception."""
fixture_server.route("/dividends", b"not-json")
fixture_server.route("/dividends", b"not-json " * 40)
with pytest.raises(errors.FmpDecodeError):
client.calendar.dividends("AAPL")
4 changes: 2 additions & 2 deletions crates/fmp-py/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,14 @@ def test_status_error_is_structured(client: Any, fixture_server: FixtureServer,

def test_decode_error_is_structured(client: Any, fixture_server: FixtureServer, errors: SimpleNamespace) -> None:
"""A 200 with an undecodable body raises ``FmpDecodeError``."""
fixture_server.route(QUOTE_SHORT_PATH, b"not-json")
fixture_server.route(QUOTE_SHORT_PATH, b"not-json " * 40)
with pytest.raises(errors.FmpDecodeError) as raised:
client.quote.short("INVALID_JSON")
error = raised.value
assert error.category == "decode"
assert error.endpoint == "quote-short"
assert error.status == 200
assert error.body == "not-json"
assert error.body == "not-json " * 40
assert error.body_truncated is False


Expand Down
24 changes: 24 additions & 0 deletions crates/fmp-py/tests/test_economics.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,27 @@ def test_decode_error_names_the_risk_premium_endpoint(
with pytest.raises(errors.FmpDecodeError) as raised:
client.economics.market_risk_premium()
assert raised.value.endpoint == "market-risk-premium"


ERROR_MESSAGE_BODY = (
'{\n "Error Message": "No Data for this symbol or invalid API call. '
'Please retry or visit our documentation at https://financialmodelingprep.com/developer/docs."\n}'
)


@pytest.mark.parametrize("body", ["Invalid name", ERROR_MESSAGE_BODY], ids=["plain-text", "error-message-object"])
def test_provider_message_with_a_success_status_is_a_status_error(
client: Any, fixture_server: FixtureServer, errors: SimpleNamespace, body: str
) -> None:
"""A 200 whose body is FMP's own error message raises ``FmpStatusError``, not ``FmpDecodeError``."""
fixture_server.route("/economic-indicators", body.encode())
with pytest.raises(errors.FmpStatusError) as raised:
client.economics.indicators("notARealIndicator")
error = raised.value
assert not isinstance(error, errors.FmpDecodeError)
assert error.category == "status"
assert error.endpoint == "economic-indicators"
assert error.status == 200
assert error.body == body
assert error.decode_kind is None
assert str(error).startswith("provider returned an error message with HTTP status 200")
2 changes: 1 addition & 1 deletion crates/fmp-py/tests/test_funds_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ def test_status_error_carries_the_funds_endpoint_id(

def test_non_json_body_is_a_decode_error(client: Any, fixture_server: FixtureServer, errors: SimpleNamespace) -> None:
"""A non-JSON body on the ETF path maps to ``FmpDecodeError``."""
fixture_server.route("/etf/holdings", b"not-json")
fixture_server.route("/etf/holdings", b"not-json " * 40)
with pytest.raises(errors.FmpDecodeError):
client.funds.etf_holdings("SPY")

Expand Down
2 changes: 1 addition & 1 deletion crates/fmp-py/tests/test_news_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,6 @@ def test_status_error_carries_the_news_endpoint_id(

def test_non_json_body_is_a_decode_error(client: Any, fixture_server: FixtureServer, errors: SimpleNamespace) -> None:
"""A non-JSON body on the authored feed maps to ``FmpDecodeError``."""
fixture_server.route("/fmp-articles", b"not-json")
fixture_server.route("/fmp-articles", b"not-json " * 40)
with pytest.raises(errors.FmpDecodeError):
client.news.articles()
2 changes: 1 addition & 1 deletion crates/fmp-py/tests/test_screener.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def test_status_error_carries_the_screener_endpoint_id(

def test_decode_error_for_a_non_json_body(client: Any, fixture_server: FixtureServer, errors: SimpleNamespace) -> None:
"""A body that is not JSON maps to ``FmpDecodeError`` rather than a raw exception."""
fixture_server.route("/company-screener", b"not-json")
fixture_server.route("/company-screener", b"not-json " * 40)
with pytest.raises(errors.FmpDecodeError) as caught:
client.screener.companies()

Expand Down
2 changes: 1 addition & 1 deletion crates/fmp-py/tests/test_sec_filings_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ def test_status_error_carries_the_sec_endpoint_id(

def test_non_json_body_is_a_decode_error(client: Any, fixture_server: FixtureServer, errors: SimpleNamespace) -> None:
"""A non-JSON body on a lookup maps to ``FmpDecodeError``."""
fixture_server.route("/sec-profile", b"not-json")
fixture_server.route("/sec-profile", b"not-json " * 40)
with pytest.raises(errors.FmpDecodeError):
client.sec_filings.company_profile("AAPL")

Expand Down
16 changes: 9 additions & 7 deletions crates/libfmp/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use url::Url;
use crate::{
Result, VERSION,
config::{Authentication, DEFAULT_BASE_URL, DEFAULT_PATH_PREFIX, RedirectPolicy},
endpoints::{QueryEncoder, ResponseMetadata},
endpoints::{DecodeFailure, QueryEncoder, ResponseMetadata},
error::{ConfigurationErrorKind, Error, Redactor, SafeBody, SecretString},
transport::{HttpExecutor, PreparedRequest, ReqwestExecutor, TransportResponse},
};
Expand Down Expand Up @@ -489,17 +489,19 @@ impl Client {
response.body_bytes(),
ResponseMetadata::new(content_type, content_disposition),
)
.map_err(|failure| {
Error::decode(
.map_err(|failure| match failure {
DecodeFailure::Shape { path, kind } => Error::decode(
Some(endpoint.id()),
Some(response.status()),
Some(safe_body(response.body(), redactor)),
"successful response could not be decoded",
)
.with_decode_location(
failure.path.map(|path| redactor.redact(&path)),
failure.kind,
)
.with_decode_location(path.map(|path| redactor.redact(&path)), kind),
DecodeFailure::ProviderMessage => Error::provider_message(
endpoint.id(),
response.status(),
Some(safe_body(response.body(), redactor)),
),
});
}

Expand Down
18 changes: 18 additions & 0 deletions crates/libfmp/src/endpoints/economics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ impl Client {
/// The documented 90-day maximum is advisory metadata. Use
/// [`EndpointBounds::accepts_date_range`](super::metadata::EndpointBounds::accepts_date_range)
/// for optional preflight validation.
///
/// The provider also caps the rows returned per call (about 60 observed
/// live), silently dropping the rest of a longer window. Walk long ranges
/// in short windows and merge the pages.
pub async fn treasury_rates(
&self,
query: impl Into<TreasuryRatesQuery>,
Expand All @@ -161,6 +165,20 @@ impl Client {
/// The documented 90-day maximum is advisory metadata. Use
/// [`EndpointBounds::accepts_date_range`](super::metadata::EndpointBounds::accepts_date_range)
/// for optional preflight validation.
///
/// The provider caps the rows returned per call and can return fewer rows
/// than the window holds, or none: observed live, a one-year CPI window
/// returned 2 rows, a 91-day window 3, and a six-year GDP window `[]`.
/// Walk long ranges in short windows and merge the pages.
///
/// # Errors
///
/// An unknown indicator name is answered with HTTP 200 and the plain-text
/// body `Invalid name`; it surfaces as an [`ErrorCategory::Status`]
/// error whose [`Error::status_code`] is `Some(200)`, not a decode error.
///
/// [`ErrorCategory::Status`]: crate::error::ErrorCategory::Status
/// [`Error::status_code`]: crate::Error::status_code
pub async fn economic_indicators(
&self,
query: impl Into<EconomicIndicatorsQuery>,
Expand Down
61 changes: 50 additions & 11 deletions crates/libfmp/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ pub mod technical_indicators;
pub mod tipranks;
pub mod transcripts;

use std::{fmt, marker::PhantomData};
use std::{collections::BTreeMap, fmt, marker::PhantomData};

use bytes::Bytes;
use serde::de::DeserializeOwned;
use serde::de::{DeserializeOwned, IgnoredAny};

use crate::{error::DecodeErrorKind, transport::HttpMethod};
use metadata::EndpointMetadata;
Expand Down Expand Up @@ -335,14 +335,20 @@ impl<'a> ResponseMetadata<'a> {
}
}

/// Where and why a response body failed to decode.
///
/// Only the member path and a coarse kind survive: the decoder's own message
/// can quote the offending value, so it is classified and then dropped.
/// Why a successful response body did not yield the documented value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DecodeFailure {
pub(crate) path: Option<String>,
pub(crate) kind: DecodeErrorKind,
pub(crate) enum DecodeFailure {
/// The body does not match the documented shape.
///
/// Only the member path and a coarse kind survive: the decoder's own
/// message can quote the offending value, so it is classified and then
/// dropped.
Shape {
path: Option<String>,
kind: DecodeErrorKind,
},
/// The provider answered a success status with its own error message.
ProviderMessage,
}

type Decoder<R> = for<'a> fn(Bytes, ResponseMetadata<'a>) -> std::result::Result<R, DecodeFailure>;
Expand Down Expand Up @@ -411,16 +417,49 @@ fn decode_json<R>(
where
R: DeserializeOwned,
{
if is_provider_message(&body) {
return Err(DecodeFailure::ProviderMessage);
}
let mut deserializer = serde_json::Deserializer::from_slice(&body);
let value = serde_path_to_error::deserialize(&mut deserializer)
.map_err(|error| decode_failure(error.path(), error.inner()))?;
deserializer.end().map_err(|_| DecodeFailure {
deserializer.end().map_err(|_| DecodeFailure::Shape {
path: None,
kind: DecodeErrorKind::Syntax,
})?;
Ok(value)
}

const MAX_PLAIN_TEXT_MESSAGE_BYTES: usize = 256;
const MAX_ERROR_MESSAGE_OBJECT_BYTES: usize = 1024;
const ERROR_MESSAGE_MEMBER: &str = "Error Message";

/// Recognizes the two error shapes FMP sends with HTTP 200.
///
/// One is a short plain-text line such as `Invalid name` that is not JSON at
/// all; the other is a JSON object whose only member is `"Error Message"`.
/// Both checks are bounded by size so ordinary payloads are not parsed twice.
fn is_provider_message(body: &[u8]) -> bool {
let body = body.trim_ascii();
match body.first() {
None => false,
Some(b'{') => {
body.len() <= MAX_ERROR_MESSAGE_OBJECT_BYTES
&& serde_json::from_slice::<BTreeMap<String, IgnoredAny>>(body).is_ok_and(
|members| members.len() == 1 && members.contains_key(ERROR_MESSAGE_MEMBER),
)
}
Some(b'[') => false,
Some(_) => {
body.len() <= MAX_PLAIN_TEXT_MESSAGE_BYTES
&& body
.iter()
.all(|byte| byte.is_ascii_graphic() || *byte == b' ')
&& serde_json::from_slice::<IgnoredAny>(body).is_err()
}
}
}

fn decode_failure(path: &serde_path_to_error::Path, error: &serde_json::Error) -> DecodeFailure {
let message = error.to_string();
let kind = match error.classify() {
Expand All @@ -436,7 +475,7 @@ fn decode_failure(path: &serde_path_to_error::Path, error: &serde_json::Error) -
None => member.to_owned(),
});
}
DecodeFailure { path, kind }
DecodeFailure::Shape { path, kind }
}

fn data_error_kind(message: &str) -> DecodeErrorKind {
Expand Down
25 changes: 24 additions & 1 deletion crates/libfmp/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ pub enum ErrorCategory {
Configuration,
/// A request could not be completed by the transport.
Transport,
/// The provider returned a non-success HTTP status.
/// The provider returned a non-success HTTP status, or answered a success
/// status with its own error message instead of the documented payload.
Status,
/// A successful response could not be decoded.
Decode,
Expand Down Expand Up @@ -554,6 +555,28 @@ impl Error {
error
}

/// Creates an error for a success status whose body is a provider error
/// message, such as FMP's plain-text `Invalid name` or a JSON object whose
/// only member is `"Error Message"`.
///
/// The category is [`ErrorCategory::Status`] and the status is the
/// success code the provider sent. `endpoint` is a static logical identity
/// or relative path, never a URL.
pub(crate) fn provider_message(
endpoint: &'static str,
status: u16,
body: Option<SafeBody>,
) -> Self {
let mut error = Self::new(
ErrorCategory::Status,
format!("provider returned an error message with HTTP status {status}"),
)
.with_endpoint(Some(endpoint));
error.status = Some(status);
error.body = body;
error
}

/// Creates a response decoding error with available response context.
///
/// `endpoint` is a static logical identity or relative path, never a URL,
Expand Down
4 changes: 2 additions & 2 deletions crates/libfmp/tests/decode_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ async fn malformed_and_trailing_json_are_syntax_failures() {

#[tokio::test]
async fn non_json_and_empty_bodies_are_root_syntax_failures() {
for body in ["not-json", ""] {
let error = decode_error(json_body(body.to_owned())).await;
for body in ["not-json ".repeat(40), String::new()] {
let error = decode_error(json_body(body)).await;

assert_eq!(error.decode_kind(), Some(DecodeErrorKind::Syntax));
assert_eq!(error.decode_path(), None);
Expand Down
Loading
Loading