From 75e54de034991c038ddce91c8c1009252ba088e9 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 29 Sep 2026 09:02:54 +0800 Subject: [PATCH 1/8] build(deps): add serde_path_to_error dependency for libfmp Introduce the `serde_path_to_error` crate as a workspace dependency and wire it into the libfmp crate to support decode path reporting. Refs #366 --- Cargo.lock | 12 ++++++++++++ Cargo.toml | 1 + crates/libfmp/Cargo.toml | 1 + 3 files changed, 14 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 65ea3603..3544b394 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,6 +716,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_path_to_error", "tokio", "url", ] @@ -1433,6 +1434,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 8d475e9c..eeb9439a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ pyo3-stub-gen = { version = "0.23", default-features = false, features = ["infer reqwest = { version = "0.13.4", default-features = false } serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.149", features = ["arbitrary_precision"] } +serde_path_to_error = "0.1.20" syn = { version = "2.0", features = ["full", "extra-traits"] } thiserror = "2.0" tokio = { version = "1.52.3", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } diff --git a/crates/libfmp/Cargo.toml b/crates/libfmp/Cargo.toml index d0d11839..7fd53c52 100644 --- a/crates/libfmp/Cargo.toml +++ b/crates/libfmp/Cargo.toml @@ -35,6 +35,7 @@ http.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +serde_path_to_error.workspace = true tokio.workspace = true url.workspace = true From 7b728c3a4ec98076a9c0a4268a5326451a7d5cc9 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 29 Sep 2026 09:03:03 +0800 Subject: [PATCH 2/8] feat(libfmp/artifact): add null-beta screener fixture Synthetic 40-row company-screener fixture whose row 37 carries beta null, for the decode-path acceptance tests (issue 366) --- crates/libfmp/tests/fixtures/README.md | 7 + .../company_screener_null_beta_synthetic.json | 682 ++++++++++++++++++ 2 files changed, 689 insertions(+) create mode 100644 crates/libfmp/tests/fixtures/company_screener_null_beta_synthetic.json diff --git a/crates/libfmp/tests/fixtures/README.md b/crates/libfmp/tests/fixtures/README.md index 0923da43..381d6b3d 100644 --- a/crates/libfmp/tests/fixtures/README.md +++ b/crates/libfmp/tests/fixtures/README.md @@ -37,6 +37,13 @@ network connection. `balance`, in the same three test suites. Issue #341 gave `income_statement_fractional_synthetic.json` a fractional `revenue` and an integral-float `ebitda` to prove `StatementAmount` decoding the same way. +- **Decode-path proof.** `company_screener_null_beta_synthetic.json` is the + one null variant kept as a file: 40 documented rows with `"beta": null` in + row 37, so the offending member lands past the `MAX_SAFE_BODY_BYTES` body + excerpt and the Go and Python suites can share it. It is the issue #366 + decode-path proof in `decode_path.rs`, `sdk/go/decode_path_test.go`, and + the fmp-py screener tests, which also swap in a string value to prove it + never reaches the error. - **Observed-shape reproductions.** `quote_short_fractional_volume.json` reproduces the one live `quote-short` row that motivated issue #337 (seen 2026-09-23: a fractional `volume`, `20201922.82733`); the four values are diff --git a/crates/libfmp/tests/fixtures/company_screener_null_beta_synthetic.json b/crates/libfmp/tests/fixtures/company_screener_null_beta_synthetic.json new file mode 100644 index 00000000..b4e38a75 --- /dev/null +++ b/crates/libfmp/tests/fixtures/company_screener_null_beta_synthetic.json @@ -0,0 +1,682 @@ +[ + { + "beta": 1.097, + "companyName": "Synthetic Row 00", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R00", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 01", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R01", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 02", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R02", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 03", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R03", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 04", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R04", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 05", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R05", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 06", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R06", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 07", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R07", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 08", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R08", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 09", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R09", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 10", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R10", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 11", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R11", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 12", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R12", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 13", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R13", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 14", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R14", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 15", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R15", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 16", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R16", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 17", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R17", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 18", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R18", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 19", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R19", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 20", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R20", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 21", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R21", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 22", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R22", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 23", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R23", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 24", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R24", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 25", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R25", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 26", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R26", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 27", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R27", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 28", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R28", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 29", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R29", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 30", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R30", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 31", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R31", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 32", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R32", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 33", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R33", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 34", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R34", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 35", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R35", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 36", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R36", + "volume": 29909012 + }, + { + "beta": null, + "companyName": "Synthetic Row 37", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R37", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 38", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R38", + "volume": 29909012 + }, + { + "beta": 1.097, + "companyName": "Synthetic Row 39", + "country": "US", + "exchange": "NASDAQ Global Select", + "exchangeShortName": "NASDAQ", + "industry": "Consumer Electronics", + "isActivelyTrading": true, + "isEtf": false, + "isFund": false, + "lastAnnualDividend": 1.05, + "marketCap": 4885602246714, + "price": 332.64001, + "sector": "Technology", + "symbol": "R39", + "volume": 29909012 + } +] From 2a39ed5d82ed8db8c6139523c4aa3017d67f1492 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 29 Sep 2026 09:06:04 +0800 Subject: [PATCH 3/8] feat(sdk/go): name JSON pointer and DecodeKind of decode failures Decode failures on *fmp.Error now locate the offending member without ever exposing its value. The Error struct gains a Path field holding the RFC 6901 JSON pointer of the failing member (such as "/37/beta") and a DecodeKind field giving a coarse, redaction-safe reason for the failure. Path is "" for a failure at the document root and on every non-decode error. DecodeKind mirrors the Rust crate's DecodeErrorKind with the stable lowercase values syntax, null, missing_member, wrong_type, and invalid_value, so the reason stays consistent across the Rust, Python, and Go SDKs. Error formatting now appends the decode kind and path. The client reads only the structured fields of the json/v2 error, never its text, and consults the body to distinguish a JSON null from an absent member. The pointer is passed through the redactor before it reaches the caller, so no member value survives on the error. --- sdk/go/README.md | 5 +- sdk/go/client.go | 5 +- sdk/go/decode.go | 74 ++++++++++++++++++++++++- sdk/go/decode_path_test.go | 110 +++++++++++++++++++++++++++++++++++++ sdk/go/errors.go | 77 +++++++++++++++++++++++++- 5 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 sdk/go/decode_path_test.go diff --git a/sdk/go/README.md b/sdk/go/README.md index 0df49387..9b4985c4 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -158,7 +158,10 @@ if errors.As(err, &fmpErr) { `Category` is one of `Validation`, `Configuration`, `Transport`, `Status`, or `Decode`. `Endpoint` is a logical endpoint id, never a URL. `Body` is the -provider body, redacted and capped. The client never retries, wraps every +provider body, redacted and capped. 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 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/client.go b/sdk/go/client.go index 35513f98..b203ad72 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -306,8 +306,11 @@ func (c *Client) getJSON(ctx context.Context, endpointID, relativePath string, q "successful response used an unexpected content type", nil) } if err := json.Unmarshal(resp.body, out); err != nil { - return decodeError(endpointID, resp.status, c.safeBody(resp.body), + decoded := decodeError(endpointID, resp.status, c.safeBody(resp.body), "successful response could not be decoded", c.redactCause(err)) + path, kind := decodeLocation(resp.body, err) + decoded.Path, decoded.DecodeKind = c.redactor.Redact(path), kind + return decoded } return nil } diff --git a/sdk/go/decode.go b/sdk/go/decode.go index 585d016b..fe00e7c2 100644 --- a/sdk/go/decode.go +++ b/sdk/go/decode.go @@ -3,6 +3,7 @@ package fmp import ( "encoding/json/jsontext" "encoding/json/v2" + "errors" "math" "strconv" ) @@ -84,8 +85,79 @@ func rawMember(members map[string]jsontext.Value, name string) jsontext.Value { func requireObjectRows(endpointID string, rows []jsontext.Value) error { for index, row := range rows { if row.Kind() != '{' { - return decodeError(endpointID, 0, nil, "row "+strconv.Itoa(index)+" is not a JSON object", nil) + rowErr := decodeError(endpointID, 0, nil, "row "+strconv.Itoa(index)+" is not a JSON object", nil) + rowErr.Path, rowErr.DecodeKind = "/"+strconv.Itoa(index), DecodeKindWrongType + return rowErr } } return nil } + +// decodeLocation turns a json.Unmarshal failure into the JSON pointer of the +// offending member and a coarse DecodeKind. It reads only the structured +// fields of the json/v2 error, never its text, so no member value survives. +// A generated model reports a missing or null required member against the +// enclosing object; the member name is appended and body is consulted to +// tell a JSON null from an absent member. +func decodeLocation(body []byte, err error) (string, DecodeKind) { + var syntactic *jsontext.SyntacticError + if errors.As(err, &syntactic) { + return string(syntactic.JSONPointer), DecodeKindSyntax + } + var semantic *json.SemanticError + if !errors.As(err, &semantic) { + return "", DecodeKindInvalidValue + } + pointer := semantic.JSONPointer + var member *Error + if errors.As(semantic.Err, &member) && member.member != "" { + pointer = pointer.AppendToken(member.member) + value, found := valueAt(body, pointer) + switch { + case !found: + return string(pointer), DecodeKindMissingMember + case value.Kind() == 'n': + return string(pointer), DecodeKindNull + default: + return string(pointer), DecodeKindWrongType + } + } + switch { + case semantic.JSONKind == 'n': + return string(pointer), DecodeKindNull + case semantic.Err == nil: + return string(pointer), DecodeKindWrongType + default: + return string(pointer), DecodeKindInvalidValue + } +} + +// valueAt walks body along pointer one level at a time and reports the value +// it names, or false when a token does not resolve. +func valueAt(body []byte, pointer jsontext.Pointer) (jsontext.Value, bool) { + value := jsontext.Value(body) + for token := range pointer.Tokens() { + switch value.Kind() { + case '[': + var items []jsontext.Value + index, err := strconv.Atoi(token) + if json.Unmarshal(value, &items) != nil || err != nil || index < 0 || index >= len(items) { + return nil, false + } + value = items[index] + case '{': + var members map[string]jsontext.Value + if json.Unmarshal(value, &members) != nil { + return nil, false + } + member, ok := members[token] + if !ok { + return nil, false + } + value = member + default: + return nil, false + } + } + return value, true +} diff --git a/sdk/go/decode_path_test.go b/sdk/go/decode_path_test.go new file mode 100644 index 00000000..5329833b --- /dev/null +++ b/sdk/go/decode_path_test.go @@ -0,0 +1,110 @@ +package fmp + +import ( + "context" + "encoding/json/jsontext" + "errors" + "net/http" + "strings" + "testing" +) + +const decodePathSentinel = "SENTINEL-beta-text" + +// decodeScreenerBody serves body as the company screener response and returns +// the Decode error the call must fail with. +func decodeScreenerBody(t *testing.T, body []byte) *Error { + t.Helper() + server, _ := newServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + client := newClient(t, server) + _, err := client.Screener.Companies(context.Background(), NewCompanyScreenerQuery()) + typed := assertQuoteError(t, err, CategoryDecode, http.StatusOK, "company-screener") + if typed.Message != "successful response could not be decoded" { + t.Fatalf("Message = %q", typed.Message) + } + return typed +} + +func TestDecodeErrorNamesTheNullMemberRowAndMember(t *testing.T) { + t.Parallel() + typed := decodeScreenerBody(t, readFixture(t, "company_screener_null_beta_synthetic.json")) + + if typed.Path != "/37/beta" || typed.DecodeKind != DecodeKindNull { + t.Fatalf("Path = %q, DecodeKind = %v, want /37/beta null", typed.Path, typed.DecodeKind) + } + want := "successful response could not be decoded: null value at /37/beta (endpoint: company-screener): " + if !strings.HasPrefix(typed.Error(), want) { + t.Fatalf("Error() = %.120q, want prefix %q", typed.Error(), want) + } +} + +func TestDecodeErrorNeverCarriesAStringMemberValue(t *testing.T) { + t.Parallel() + body := strings.Replace(string(readFixture(t, "company_screener_null_beta_synthetic.json")), + `"beta": null`, `"beta": "`+decodePathSentinel+`"`, 1) + typed := decodeScreenerBody(t, []byte(body)) + + if typed.Path != "/37/beta" || typed.DecodeKind != DecodeKindWrongType { + t.Fatalf("Path = %q, DecodeKind = %v, want /37/beta wrong_type", typed.Path, typed.DecodeKind) + } + if typed.Body == nil || !typed.Body.Truncated { + t.Fatalf("Body = %+v, want a truncated excerpt that ends before row 37", typed.Body) + } + if strings.Contains(typed.Error(), decodePathSentinel) || strings.Contains(typed.Unwrap().Error(), decodePathSentinel) { + t.Fatalf("decode error leaked the member value: %q / %q", typed.Error(), typed.Unwrap()) + } +} + +func TestDecodeErrorLocatesMissingWrongAndMalformedMembers(t *testing.T) { + t.Parallel() + row := string(readFixture(t, "company_screener.json")) + cases := []struct { + name string + body string + path string + kind DecodeKind + }{ + {"missing", strings.Replace(row, `"beta": 1.097,`, "", 1), "/0/beta", DecodeKindMissingMember}, + {"wrong type", strings.Replace(row, `"AAPL"`, "5", 1), "/0/symbol", DecodeKindWrongType}, + {"root shape", "{}", "", DecodeKindWrongType}, + {"malformed", `[{"beta": nope}]`, "/0/beta", DecodeKindSyntax}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + typed := decodeScreenerBody(t, []byte(tc.body)) + if typed.Path != tc.path || typed.DecodeKind != tc.kind { + t.Fatalf("Path = %q, DecodeKind = %v, want %q %v", typed.Path, typed.DecodeKind, tc.path, tc.kind) + } + }) + } +} + +func TestDecodeKindStringsMatchTheRustBindingValues(t *testing.T) { + t.Parallel() + want := map[DecodeKind]string{ + DecodeKindNone: "", + DecodeKindSyntax: "syntax", + DecodeKindNull: "null", + DecodeKindMissingMember: "missing_member", + DecodeKindWrongType: "wrong_type", + DecodeKindInvalidValue: "invalid_value", + } + for kind, value := range want { + if kind.String() != value { + t.Fatalf("%d.String() = %q, want %q", int(kind), kind.String(), value) + } + } +} + +func TestRequireObjectRowsLocatesTheNonObjectRow(t *testing.T) { + t.Parallel() + err := requireObjectRows("dynamic-id", []jsontext.Value{jsontext.Value(`{}`), jsontext.Value(`[1]`)}) + var typed *Error + if !errors.As(err, &typed) || typed.Path != "/1" || typed.DecodeKind != DecodeKindWrongType { + t.Fatalf("error = %+v, want Path /1 wrong_type", typed) + } +} diff --git a/sdk/go/errors.go b/sdk/go/errors.go index d511a583..b3f119e8 100644 --- a/sdk/go/errors.go +++ b/sdk/go/errors.go @@ -86,11 +86,72 @@ const ( ConfigurationKindHTTPClient ) +// DecodeKind is the coarse reason a successful JSON response failed to +// decode. It mirrors DecodeErrorKind in the Rust crate and never carries the +// offending member value. +type DecodeKind int + +const ( + // DecodeKindNone marks an error that carries no decode location. + DecodeKindNone DecodeKind = iota + // DecodeKindSyntax: the body is not well-formed JSON. + DecodeKindSyntax + // DecodeKindNull: a member that requires a value was JSON null. + DecodeKindNull + // DecodeKindMissingMember: a required object member was absent. + DecodeKindMissingMember + // DecodeKindWrongType: a member had a JSON type the model does not accept. + DecodeKindWrongType + // DecodeKindInvalidValue: a member had the right JSON type but an unacceptable value. + DecodeKindInvalidValue +) + +// String returns the stable lowercase value shared with the Rust and Python +// SDKs, or "" for DecodeKindNone. +func (k DecodeKind) String() string { + switch k { + case DecodeKindNone: + return "" + case DecodeKindSyntax: + return "syntax" + case DecodeKindNull: + return "null" + case DecodeKindMissingMember: + return "missing_member" + case DecodeKindWrongType: + return "wrong_type" + case DecodeKindInvalidValue: + return "invalid_value" + default: + return fmt.Sprintf("DecodeKind(%d)", int(k)) + } +} + +func (k DecodeKind) description() string { + switch k { + case DecodeKindSyntax: + return "malformed JSON" + case DecodeKindNull: + return "null value" + case DecodeKindMissingMember: + return "missing member" + case DecodeKindWrongType: + return "wrong type" + default: + return "invalid value" + } +} + // Error is the stable error value returned by every client operation. // // Endpoint is a logical endpoint id such as "quote-short", never a URL. // Status is zero when no provider status applies. Body is nil unless a // provider body was retained; it is always redacted and capped. +// +// Path and DecodeKind locate a JSON decode failure. Path is the RFC 6901 JSON +// 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. type Error struct { Category ErrorCategory Message string @@ -98,13 +159,25 @@ type Error struct { Status int Body *SafeBody ConfigurationKind ConfigurationKind + Path string + DecodeKind DecodeKind + member string cause error } -// Error formats the message, the endpoint id, and the safe body, in that order. +// Error formats the message, the decode kind and path, the endpoint id, and +// the safe body, in that order. func (e *Error) Error() string { var b strings.Builder b.WriteString(e.Message) + if e.DecodeKind != DecodeKindNone { + b.WriteString(": ") + b.WriteString(e.DecodeKind.description()) + } + if e.Path != "" { + b.WriteString(" at ") + b.WriteString(e.Path) + } if e.Endpoint != "" { b.WriteString(" (endpoint: ") b.WriteString(e.Endpoint) @@ -141,6 +214,7 @@ func missingMemberError(model, member string) *Error { return &Error{ Category: CategoryDecode, Message: fmt.Sprintf("required member %q of %s is missing or null", member, model), + member: member, } } @@ -150,6 +224,7 @@ func invalidMemberError(model, member, expected string) *Error { return &Error{ Category: CategoryDecode, Message: fmt.Sprintf("member %q of %s must be a JSON %s", member, model, expected), + member: member, } } From 054f7a5ad43cc7815034f5c347026ed35684a367 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 29 Sep 2026 09:07:03 +0800 Subject: [PATCH 4/8] feat(libfmp): expose decode path and kind on JSON decode failures Decode JSON responses through serde_path_to_error so failures can report where and why they occurred without leaking the offending value. **Decode diagnostics on Error:** * Added `DecodeErrorKind` in `error.rs`, a coarse, non-exhaustive classification (`syntax`, `null`, `missing_member`, `wrong_type`, `invalid_value`) with a stable lowercase `as_str` for language bindings. * Added `Error::decode_path` and `Error::decode_kind` accessors, plus the internal `with_decode_location` builder, and extended the `Display` output to include the kind description and member path. * The path names members and array indexes only, for example `[37].beta`, and is `None` for root-level and non-JSON failures such as binary bodies. **JSON decoding:** * `decode_json` now deserializes via `serde_path_to_error`, classifies the failure into a `DecodeFailure` carrying the path and kind, and discards the decoder's own message so no member value survives. * The client redacts the decode path before attaching it to the error. --- crates/libfmp/src/client.rs | 6 +- crates/libfmp/src/endpoints/mod.rs | 67 +++++++++++-- crates/libfmp/src/error.rs | 86 +++++++++++++++++ crates/libfmp/tests/decode_path.rs | 149 +++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 crates/libfmp/tests/decode_path.rs diff --git a/crates/libfmp/src/client.rs b/crates/libfmp/src/client.rs index 8bd534db..44be24fc 100644 --- a/crates/libfmp/src/client.rs +++ b/crates/libfmp/src/client.rs @@ -489,13 +489,17 @@ impl Client { response.body_bytes(), ResponseMetadata::new(content_type, content_disposition), ) - .map_err(|_| { + .map_err(|failure| { 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, + ) }); } diff --git a/crates/libfmp/src/endpoints/mod.rs b/crates/libfmp/src/endpoints/mod.rs index efe0cb26..fa67cca0 100644 --- a/crates/libfmp/src/endpoints/mod.rs +++ b/crates/libfmp/src/endpoints/mod.rs @@ -38,7 +38,7 @@ use std::{fmt, marker::PhantomData}; use bytes::Bytes; use serde::de::DeserializeOwned; -use crate::transport::HttpMethod; +use crate::{error::DecodeErrorKind, transport::HttpMethod}; use metadata::EndpointMetadata; /// The JSON media type requested by FMP's structured-data endpoints. @@ -335,7 +335,17 @@ impl<'a> ResponseMetadata<'a> { } } -type Decoder = for<'a> fn(Bytes, ResponseMetadata<'a>) -> std::result::Result; +/// 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. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DecodeFailure { + pub(crate) path: Option, + pub(crate) kind: DecodeErrorKind, +} + +type Decoder = for<'a> fn(Bytes, ResponseMetadata<'a>) -> std::result::Result; /// Decoding and media-type expectations associated with one endpoint. pub struct ResponseContract { @@ -354,7 +364,7 @@ impl ResponseContract { &self, body: Bytes, metadata: ResponseMetadata<'_>, - ) -> std::result::Result { + ) -> std::result::Result { (self.decoder)(body, metadata) } } @@ -394,11 +404,56 @@ impl fmt::Debug for ResponseContract { } } -fn decode_json(body: Bytes, _metadata: ResponseMetadata<'_>) -> std::result::Result +fn decode_json( + body: Bytes, + _metadata: ResponseMetadata<'_>, +) -> std::result::Result where R: DeserializeOwned, { - serde_json::from_slice(&body).map_err(|_| ()) + 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 { + path: None, + kind: DecodeErrorKind::Syntax, + })?; + Ok(value) +} + +fn decode_failure(path: &serde_path_to_error::Path, error: &serde_json::Error) -> DecodeFailure { + let message = error.to_string(); + let kind = match error.classify() { + serde_json::error::Category::Data => data_error_kind(&message), + _ => DecodeErrorKind::Syntax, + }; + let mut path = (path.iter().next().is_some()).then(|| path.to_string()); + if kind == DecodeErrorKind::MissingMember + && let Some(member) = missing_member_name(&message) + { + path = Some(match path { + Some(parent) => format!("{parent}.{member}"), + None => member.to_owned(), + }); + } + DecodeFailure { path, kind } +} + +fn data_error_kind(message: &str) -> DecodeErrorKind { + if message.starts_with("invalid type: null") { + DecodeErrorKind::Null + } else if message.starts_with("invalid type: ") { + DecodeErrorKind::WrongType + } else if message.starts_with("missing field ") { + DecodeErrorKind::MissingMember + } else { + DecodeErrorKind::InvalidValue + } +} + +fn missing_member_name(message: &str) -> Option<&str> { + let name = message.strip_prefix("missing field `")?; + name.split_once('`').map(|(name, _)| name) } /// An owned binary response with validated media metadata. @@ -466,7 +521,7 @@ pub type BinaryBody = BinaryResponse; fn decode_binary( body: Bytes, metadata: ResponseMetadata<'_>, -) -> std::result::Result { +) -> std::result::Result { Ok(BinaryResponse { bytes: body, content_type: metadata.content_type.into(), diff --git a/crates/libfmp/src/error.rs b/crates/libfmp/src/error.rs index d0353d0e..5a378b0f 100644 --- a/crates/libfmp/src/error.rs +++ b/crates/libfmp/src/error.rs @@ -81,6 +81,54 @@ pub enum ConfigurationErrorKind { HttpClient, } +/// Coarse reasons a successful JSON response failed to decode. +/// +/// The kind never carries the offending member value: it is derived from the +/// decoder failure and the decoder's own text is discarded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum DecodeErrorKind { + /// The body is not well-formed JSON. + Syntax, + /// A member that requires a value was JSON `null`. + Null, + /// A required object member was absent. + MissingMember, + /// A member had a JSON type the documented shape does not accept. + WrongType, + /// A member had the right JSON type but an unacceptable value. + InvalidValue, +} + +impl DecodeErrorKind { + /// Returns the stable lowercase value exposed to language bindings. + pub const fn as_str(self) -> &'static str { + match self { + Self::Syntax => "syntax", + Self::Null => "null", + Self::MissingMember => "missing_member", + Self::WrongType => "wrong_type", + Self::InvalidValue => "invalid_value", + } + } + + const fn description(self) -> &'static str { + match self { + Self::Syntax => "malformed JSON", + Self::Null => "null value", + Self::MissingMember => "missing member", + Self::WrongType => "wrong type", + Self::InvalidValue => "invalid value", + } + } +} + +impl fmt::Display for DecodeErrorKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + impl ErrorCategory { /// Returns the stable lowercase value exposed to language bindings. pub const fn as_str(self) -> &'static str { @@ -461,6 +509,8 @@ pub struct Error { status: Option, body: Option, configuration_kind: Option, + decode_path: Option>, + decode_kind: Option, } impl Error { @@ -528,9 +578,24 @@ impl Error { status: None, body: None, configuration_kind: None, + decode_path: None, + decode_kind: None, } } + /// Attaches where and why a JSON response failed to decode. + /// + /// `path` names members and array indexes only, never a member value. + pub(crate) fn with_decode_location( + mut self, + path: Option, + kind: DecodeErrorKind, + ) -> Self { + self.decode_path = path.map(String::into_boxed_str); + self.decode_kind = Some(kind); + self + } + fn with_endpoint(mut self, endpoint: Option<&'static str>) -> Self { self.endpoint = endpoint; self @@ -565,11 +630,32 @@ impl Error { pub fn configuration_kind(&self) -> Option { self.configuration_kind } + + /// Returns the location of the member that failed to decode, when known. + /// + /// The path uses array indexes and member names, for example + /// `[37].beta`, and never includes the member value. It is `None` for + /// failures at the document root and for response kinds that are not + /// decoded from JSON, such as binary bodies. + pub fn decode_path(&self) -> Option<&str> { + self.decode_path.as_deref() + } + + /// Returns the coarse reason a JSON response failed to decode, when known. + pub fn decode_kind(&self) -> Option { + self.decode_kind + } } impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.message)?; + if let Some(kind) = self.decode_kind { + write!(formatter, ": {}", kind.description())?; + } + if let Some(path) = &self.decode_path { + write!(formatter, " at {path}")?; + } if let Some(endpoint) = &self.endpoint { write!(formatter, " (endpoint: {endpoint})")?; } diff --git a/crates/libfmp/tests/decode_path.rs b/crates/libfmp/tests/decode_path.rs new file mode 100644 index 00000000..0ce35a30 --- /dev/null +++ b/crates/libfmp/tests/decode_path.rs @@ -0,0 +1,149 @@ +mod support; + +use std::sync::Arc; + +use libfmp::{ + Client, + config::Authentication, + endpoints::screener::CompanyScreenerQuery, + error::{DecodeErrorKind, ErrorCategory}, + http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}, + transport::TransportResponse, +}; + +use support::{FixtureExecutor, json_fixture}; + +const NULL_BETA: &[u8] = include_bytes!("fixtures/company_screener_null_beta_synthetic.json"); +const SCREENER: &str = include_str!("fixtures/company_screener.json"); +const SENTINEL: &str = "SENTINEL-beta-text"; + +fn json_body(body: String) -> TransportResponse { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + TransportResponse::new(200, headers, body) +} + +async fn decode_error(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.clone()) + .build() + .unwrap(); + let error = client + .company_screener(CompanyScreenerQuery::new()) + .await + .unwrap_err(); + assert_eq!(executor.requests().len(), 1); + assert_eq!(error.category(), ErrorCategory::Decode); + assert_eq!(error.endpoint(), Some("company-screener")); + assert_eq!(error.message(), "successful response could not be decoded"); + error +} + +#[tokio::test] +async fn null_member_names_the_row_index_and_member() { + let error = decode_error(json_fixture(NULL_BETA)).await; + + assert_eq!(error.decode_path(), Some("[37].beta")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::Null)); + assert!(error.to_string().starts_with( + "successful response could not be decoded: null value at [37].beta \ + (endpoint: company-screener): " + )); +} + +#[tokio::test] +async fn string_valued_member_never_reaches_the_error_text() { + let body = String::from_utf8(NULL_BETA.to_vec()).unwrap().replacen( + "\"beta\": null", + &format!("\"beta\": \"{SENTINEL}\""), + 1, + ); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[37].beta")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::WrongType)); + assert!(error.body().unwrap().is_truncated()); + assert!(!error.to_string().contains(SENTINEL)); + assert!(!format!("{error:?}").contains(SENTINEL)); +} + +#[tokio::test] +async fn location_fields_omit_the_value_even_when_the_body_retains_it() { + let body = SCREENER.replacen("1.097", &format!("\"{SENTINEL}\""), 1); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[0].beta")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::WrongType)); + assert!(error.body().unwrap().as_str().contains(SENTINEL)); + assert!(!error.message().contains(SENTINEL)); + assert!(!error.decode_path().unwrap().contains(SENTINEL)); +} + +#[tokio::test] +async fn missing_member_path_names_the_absent_member() { + let body = SCREENER.replacen("\"beta\": 1.097,", "", 1); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[0].beta")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::MissingMember)); +} + +#[tokio::test] +async fn number_for_a_string_member_is_a_wrong_type_at_that_member() { + let body = SCREENER.replacen("\"AAPL\"", "5", 1); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[0].symbol")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::WrongType)); +} + +#[tokio::test] +async fn rejected_string_value_is_an_invalid_value_without_its_text() { + let body = SCREENER.replacen("\"AAPL\"", "\" \"", 1); + let error = decode_error(json_body(body)).await; + + assert_eq!(error.decode_path(), Some("[0].symbol")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::InvalidValue)); +} + +#[tokio::test] +async fn root_shape_mismatch_has_a_kind_but_no_path() { + let error = decode_error(json_body("{}".to_owned())).await; + + assert_eq!(error.decode_path(), None); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::WrongType)); + assert!( + error + .to_string() + .starts_with("successful response could not be decoded: wrong type (endpoint:") + ); +} + +#[tokio::test] +async fn malformed_and_trailing_json_are_syntax_failures() { + let malformed = decode_error(json_body("[{\"beta\": nope}]".to_owned())).await; + let trailing = decode_error(json_body("[] []".to_owned())).await; + + assert_eq!(malformed.decode_kind(), Some(DecodeErrorKind::Syntax)); + assert_eq!(trailing.decode_kind(), Some(DecodeErrorKind::Syntax)); + assert_eq!(trailing.decode_path(), None); +} + +#[test] +fn decode_kind_exposes_stable_binding_values() { + let kinds = [ + (DecodeErrorKind::Syntax, "syntax"), + (DecodeErrorKind::Null, "null"), + (DecodeErrorKind::MissingMember, "missing_member"), + (DecodeErrorKind::WrongType, "wrong_type"), + (DecodeErrorKind::InvalidValue, "invalid_value"), + ]; + for (kind, value) in kinds { + assert_eq!(kind.as_str(), value); + assert_eq!(kind.to_string(), value); + } +} From 1c41f61a19fc0e436afecd997dbaf8d60fdecb90 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 29 Sep 2026 09:29:39 +0800 Subject: [PATCH 5/8] feat(fmp-py): expose decode_path and decode_kind on FmpError Surface where and why a JSON response failed to decode without leaking the offending member value. `FmpError` now carries two new attributes: `decode_path`, naming the member (for example `[37].beta`), and `decode_kind`, giving the reason (`syntax`, `null`, `missing_member`, `wrong_type`, or `invalid_value`). These attributes let callers locate a decode failure precisely while keeping response contents out of both the message and the error object. Tests confirm the path and kind are reported and that the offending string value never reaches the message, repr, or any attribute. close #366 --- crates/fmp-py/README.md | 4 +-- .../python/fmp/_native/errors/__init__.pyi | 12 +++++++ crates/fmp-py/src/errors.rs | 25 +++++++++++++ crates/fmp-py/tests/test_import.py | 1 + crates/fmp-py/tests/test_screener.py | 35 +++++++++++++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/fmp-py/README.md b/crates/fmp-py/README.md index da73e226..74fac430 100644 --- a/crates/fmp-py/README.md +++ b/crates/fmp-py/README.md @@ -142,12 +142,12 @@ package root): | Exception | Raised when | |-----------|-------------| -| `FmpError` | base class; carries `category`, `endpoint`, `status`, `body`, `body_truncated` | +| `FmpError` | base class; carries `category`, `endpoint`, `status`, `body`, `body_truncated`, `decode_path`, `decode_kind` | | `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 | -| `FmpDecodeError` | the body could not be decoded into the documented shape | +| `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 from fmp import FmpClient diff --git a/crates/fmp-py/python/fmp/_native/errors/__init__.pyi b/crates/fmp-py/python/fmp/_native/errors/__init__.pyi index 0d6e47b5..d7e50af1 100644 --- a/crates/fmp-py/python/fmp/_native/errors/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/errors/__init__.pyi @@ -64,6 +64,18 @@ class FmpError(builtins.Exception): Whether `body` was cut short to stay within the excerpt bound. """ + @property + def decode_path(self) -> typing.Optional[builtins.str]: + r""" + Where a JSON response failed to decode, for example `"[37].beta"`; never the member value. + """ + + @property + def decode_kind(self) -> typing.Optional[builtins.str]: + r""" + Why a JSON response failed to decode: `"syntax"`, `"null"`, `"missing_member"`, `"wrong_type"`, or `"invalid_value"`. + """ + class FmpStatusError(FmpError): r""" diff --git a/crates/fmp-py/src/errors.rs b/crates/fmp-py/src/errors.rs index 098a8f35..c16b542d 100644 --- a/crates/fmp-py/src/errors.rs +++ b/crates/fmp-py/src/errors.rs @@ -64,6 +64,20 @@ static ERROR_ATTRIBUTES: &[MemberInfo] = &[ default: None, deprecated: None, }, + MemberInfo { + name: "decode_path", + r#type: as PyStubType>::type_output, + doc: "Where a JSON response failed to decode, for example `\"[37].beta\"`; never the member value.", + default: None, + deprecated: None, + }, + MemberInfo { + name: "decode_kind", + r#type: as PyStubType>::type_output, + doc: "Why a JSON response failed to decode: `\"syntax\"`, `\"null\"`, `\"missing_member\"`, `\"wrong_type\"`, or `\"invalid_value\"`.", + default: None, + deprecated: None, + }, ]; /// Declares one exception type: the pyo3 type object under `fmp.errors` plus @@ -188,6 +202,8 @@ fn set_validation_attributes(exception: &Bound<'_, PyBaseException>) -> PyResult exception.setattr("status", py.None())?; exception.setattr("body", py.None())?; exception.setattr("body_truncated", py.None())?; + exception.setattr("decode_path", py.None())?; + exception.setattr("decode_kind", py.None())?; Ok(()) } @@ -203,6 +219,13 @@ fn set_error_attributes( "body_truncated", error.body().map(libfmp::error::SafeBody::is_truncated), )?; + exception.setattr("decode_path", error.decode_path())?; + exception.setattr( + "decode_kind", + error + .decode_kind() + .map(libfmp::error::DecodeErrorKind::as_str), + )?; Ok(()) } @@ -219,6 +242,8 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { base.setattr("status", py.None())?; base.setattr("body", py.None())?; base.setattr("body_truncated", py.None())?; + base.setattr("decode_path", py.None())?; + base.setattr("decode_kind", py.None())?; py.get_type::() .setattr("category", "validation")?; diff --git a/crates/fmp-py/tests/test_import.py b/crates/fmp-py/tests/test_import.py index a7d11560..b3232b1d 100644 --- a/crates/fmp-py/tests/test_import.py +++ b/crates/fmp-py/tests/test_import.py @@ -245,3 +245,4 @@ def test_exception_attributes_default_to_none(errors: SimpleNamespace, name: str assert exception.args == ("message",) 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) diff --git a/crates/fmp-py/tests/test_screener.py b/crates/fmp-py/tests/test_screener.py index 04479d15..191d7c36 100644 --- a/crates/fmp-py/tests/test_screener.py +++ b/crates/fmp-py/tests/test_screener.py @@ -217,6 +217,41 @@ def test_decode_error_for_a_non_json_body(client: Any, fixture_server: FixtureSe client.screener.companies() +def test_decode_error_names_the_null_member_row_and_member( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """Issue #366: a ``null`` required member names its row index and member.""" + fixture_server.route("/company-screener", load_fixture("company_screener_null_beta_synthetic.json")) + with pytest.raises(errors.FmpDecodeError) as caught: + client.screener.companies() + + assert caught.value.decode_path == "[37].beta" + assert caught.value.decode_kind == "null" + assert str(caught.value).startswith( + "successful response could not be decoded: null value at [37].beta (endpoint: company-screener): " + ) + + +def test_decode_error_never_carries_a_string_member_value( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """Issue #366: the offending string value reaches neither the message nor an attribute.""" + sentinel = "SENTINEL-beta-text" + rows = load_fixture("company_screener_null_beta_synthetic.json") + rows[37]["beta"] = sentinel + fixture_server.route("/company-screener", rows) + with pytest.raises(errors.FmpDecodeError) as caught: + client.screener.companies() + + error = caught.value + assert error.decode_path == "[37].beta" + assert error.decode_kind == "wrong_type" + assert error.body_truncated is True + assert sentinel not in str(error) + assert sentinel not in repr(error) + assert all(sentinel not in str(value) for value in vars(error).values()) + + def test_companies_decodes_integral_float_and_fractional_market_caps(client: Any, fixture_server: FixtureServer) -> None: """Issue #339: an integral-float or fractional ``marketCap`` decodes as a ``float``.""" fixture_server.route("/company-screener", load_fixture("company_screener_fractional_synthetic.json")) From b2285346d7e1edbd30456b0853444e912529ce2f Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 29 Sep 2026 09:40:12 +0800 Subject: [PATCH 6/8] test(decode-path): assert non-json bodies are root syntax failures Extend the decode path test suites across the Python, Rust, and Go SDKs to verify that non-JSON and empty response bodies surface as root-level syntax failures with no decode path. * Python: assert the raised `FmpDecodeError` reports a `syntax` kind and `None` path for a non-JSON body. * Rust: add a case covering both non-JSON and empty bodies as root syntax failures. * Go: add a `not json` case expecting an empty path and `DecodeKindSyntax`. --- crates/fmp-py/tests/test_screener.py | 4 +++- crates/libfmp/tests/decode_path.rs | 10 ++++++++++ sdk/go/decode_path_test.go | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/fmp-py/tests/test_screener.py b/crates/fmp-py/tests/test_screener.py index 191d7c36..528a24e7 100644 --- a/crates/fmp-py/tests/test_screener.py +++ b/crates/fmp-py/tests/test_screener.py @@ -213,9 +213,11 @@ 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") - with pytest.raises(errors.FmpDecodeError): + with pytest.raises(errors.FmpDecodeError) as caught: client.screener.companies() + assert (caught.value.decode_kind, caught.value.decode_path) == ("syntax", None) + def test_decode_error_names_the_null_member_row_and_member( client: Any, fixture_server: FixtureServer, errors: SimpleNamespace diff --git a/crates/libfmp/tests/decode_path.rs b/crates/libfmp/tests/decode_path.rs index 0ce35a30..5593b43e 100644 --- a/crates/libfmp/tests/decode_path.rs +++ b/crates/libfmp/tests/decode_path.rs @@ -133,6 +133,16 @@ async fn malformed_and_trailing_json_are_syntax_failures() { assert_eq!(trailing.decode_path(), None); } +#[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; + + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::Syntax)); + assert_eq!(error.decode_path(), None); + } +} + #[test] fn decode_kind_exposes_stable_binding_values() { let kinds = [ diff --git a/sdk/go/decode_path_test.go b/sdk/go/decode_path_test.go index 5329833b..ae46a670 100644 --- a/sdk/go/decode_path_test.go +++ b/sdk/go/decode_path_test.go @@ -71,6 +71,7 @@ func TestDecodeErrorLocatesMissingWrongAndMalformedMembers(t *testing.T) { {"wrong type", strings.Replace(row, `"AAPL"`, "5", 1), "/0/symbol", DecodeKindWrongType}, {"root shape", "{}", "", DecodeKindWrongType}, {"malformed", `[{"beta": nope}]`, "/0/beta", DecodeKindSyntax}, + {"not json", "not-json", "", DecodeKindSyntax}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { From e8676122aae1d09d16ea6d203c67a0a892cea95c Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 29 Sep 2026 09:41:03 +0800 Subject: [PATCH 7/8] docs(sdk): document DecodeKind value stability guarantee Clarify that DecodeKind values are stable and that new kinds are only appended, so consumers can rely on existing values not changing. --- sdk/go/errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/go/errors.go b/sdk/go/errors.go index b3f119e8..4154fa94 100644 --- a/sdk/go/errors.go +++ b/sdk/go/errors.go @@ -88,7 +88,7 @@ const ( // DecodeKind is the coarse reason a successful JSON response failed to // decode. It mirrors DecodeErrorKind in the Rust crate and never carries the -// offending member value. +// offending member value. The values are stable: new kinds are only appended. type DecodeKind int const ( From dc9a4491495516c68ee84b543e6a3b5702430d88 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 29 Sep 2026 11:01:50 +0800 Subject: [PATCH 8/8] fix(decode): expose decode path and kind for invalid member values The Go SDK previously reported a fractional count and other value-level decode failures as a generic wrong-type error, so callers could not tell where in the response the rejected value lived or why it was rejected. **Decode error classification**: * Added `invalidMemberValueError` in `sdk/go/decode.go`, which marks a member whose JSON kind is correct but whose value the Rust decoder rejects, such as a non-integral count. * Tracked the new `memberKind` on the error and surfaced it through the decode path/kind resolution so such members report their own kind instead of defaulting to `DecodeKindWrongType`. **Test coverage**: * Extended the Go, Rust, and Python analyst tests to assert the decode path (`/0/buy`, `[0].buy`), the `InvalidValue` kind, and that the member value is never leaked into the error message. --- crates/fmp-py/tests/test_analyst.py | 4 +++- crates/libfmp/tests/analyst_stock_grades_endpoints.rs | 5 ++++- sdk/go/analyst_test.go | 8 +++++++- sdk/go/decode.go | 4 +++- sdk/go/errors.go | 9 +++++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/fmp-py/tests/test_analyst.py b/crates/fmp-py/tests/test_analyst.py index 5464f53f..243ca3b8 100644 --- a/crates/fmp-py/tests/test_analyst.py +++ b/crates/fmp-py/tests/test_analyst.py @@ -232,11 +232,13 @@ def test_stock_grades_summary_accepts_an_integral_float_count(client: Any, fixtu def test_stock_grades_summary_rejects_a_fractional_count( client: Any, fixture_server: FixtureServer, errors: SimpleNamespace ) -> None: - """A fractional count such as ``2.9`` maps to ``FmpDecodeError`` with the endpoint id.""" + """A fractional count such as ``2.9`` maps to ``FmpDecodeError`` naming the endpoint and member.""" fixture_server.route("/grades-consensus", [{**load_fixture("stock_grades_summary.json")[0], "buy": 2.9}]) with pytest.raises(errors.FmpDecodeError) as raised: client.analyst.stock_grades_summary("AAPL") assert raised.value.endpoint == "grades-consensus" + assert raised.value.decode_path == "[0].buy" + assert raised.value.decode_kind == "invalid_value" def test_empty_array_decodes_to_an_empty_list(client: Any, fixture_server: FixtureServer) -> None: diff --git a/crates/libfmp/tests/analyst_stock_grades_endpoints.rs b/crates/libfmp/tests/analyst_stock_grades_endpoints.rs index 9226adf1..cfb56427 100644 --- a/crates/libfmp/tests/analyst_stock_grades_endpoints.rs +++ b/crates/libfmp/tests/analyst_stock_grades_endpoints.rs @@ -13,7 +13,7 @@ use libfmp::{ }, metadata::{AccessRequirement, EndpointBounds, GeographicAvailability}, }, - error::ErrorCategory, + error::{DecodeErrorKind, ErrorCategory}, responses::analyst::{HistoricalStockGrade, StockGrade, StockGradesSummary}, transport::HttpMethod, types::{Limit, Ticker}, @@ -288,6 +288,9 @@ async fn count_members_accept_integral_floats_and_reject_fractions() { let error = client.stock_grades_summary(query()).await.unwrap_err(); assert_decode_error(&error, "grades-consensus"); + assert_eq!(error.decode_path(), Some("[0].buy")); + assert_eq!(error.decode_kind(), Some(DecodeErrorKind::InvalidValue)); + assert!(!error.message().contains("2.9")); } fn assert_decode_error(error: &libfmp::Error, endpoint: &'static str) { diff --git a/sdk/go/analyst_test.go b/sdk/go/analyst_test.go index f77fb8a2..50ec44da 100644 --- a/sdk/go/analyst_test.go +++ b/sdk/go/analyst_test.go @@ -161,5 +161,11 @@ func TestAnalystCountMembersAcceptIntegralFloatsAndRejectFractions(t *testing.T) server, _ = newServer(t, jsonHandler(fmt.Sprintf(row, "2.9"))) client = newClient(t, server, WithAuthentication(FMPHeader("route-secret"))) _, err = client.Analyst.StockGradesSummary(ctx, NewStockGradesSummaryQuery("AAPL")) - _ = assertQuoteError(t, err, CategoryDecode, http.StatusOK, "grades-consensus") + typed := assertQuoteError(t, err, CategoryDecode, http.StatusOK, "grades-consensus") + if typed.Path != "/0/buy" || typed.DecodeKind != DecodeKindInvalidValue { + t.Fatalf("Path, DecodeKind = %q, %v, want /0/buy, invalid_value", typed.Path, typed.DecodeKind) + } + if strings.Contains(typed.Message, "2.9") { + t.Fatalf("Message = %q, must not contain the member value", typed.Message) + } } diff --git a/sdk/go/decode.go b/sdk/go/decode.go index fe00e7c2..4c0de85d 100644 --- a/sdk/go/decode.go +++ b/sdk/go/decode.go @@ -62,7 +62,7 @@ func decodeCount(model, member string, raw jsontext.Value) (uint64, error) { } value, err := strconv.ParseFloat(text, 64) if err != nil || value < 0 || value >= 0x1p64 || value != math.Trunc(value) { - return 0, invalidMemberError(model, member, "non-negative integral number") + return 0, invalidMemberValueError(model, member, "non-negative integral number") } return uint64(value), nil } @@ -118,6 +118,8 @@ func decodeLocation(body []byte, err error) (string, DecodeKind) { return string(pointer), DecodeKindMissingMember case value.Kind() == 'n': return string(pointer), DecodeKindNull + case member.memberKind != DecodeKindNone: + return string(pointer), member.memberKind default: return string(pointer), DecodeKindWrongType } diff --git a/sdk/go/errors.go b/sdk/go/errors.go index 4154fa94..1e5257ca 100644 --- a/sdk/go/errors.go +++ b/sdk/go/errors.go @@ -162,6 +162,7 @@ type Error struct { Path string DecodeKind DecodeKind member string + memberKind DecodeKind cause error } @@ -228,6 +229,14 @@ func invalidMemberError(model, member, expected string) *Error { } } +// invalidMemberValueError reports a member whose JSON kind is right but +// whose value the Rust decoder would reject, such as a fractional count. +func invalidMemberValueError(model, member, expected string) *Error { + err := invalidMemberError(model, member, expected) + err.memberKind = DecodeKindInvalidValue + return err +} + func transportError(endpoint, message string, cause error) *Error { return &Error{Category: CategoryTransport, Message: message, Endpoint: endpoint, cause: cause} }