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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@

All notable changes to the USDA FDC Python Client will be documented in this file.

## [Unreleased]

### Added
- `status_code` on `FdcApiError` and every exception deriving from it, holding
the HTTP status the API replied with (`None` when the request never reached
the API, as with a refused connection or a timeout). The documentation has
told callers to branch on this attribute for some time, including a
retry-on-5xx example that could never have run: nothing ever set it, so
`hasattr(e, 'status_code')` was always `False`.
- `get_dri_value`, which returns a DRI together with its unit.
- `FdcValidationError` and `FdcResourceNotFoundError` are now exported from the
package root, alongside the exceptions that were already there.

### Fixed
- **An invalid API key raised a nondescript `FdcApiError`.** FDC sits behind
api.data.gov, which rejects a bad key with HTTP **403**, while only 401 was
being mapped — so the single most common mistake a caller can make missed
`FdcAuthError` entirely. Both statuses now raise it.
- **A missing food and a rejected request were indistinguishable from a broken
API.** HTTP 404 now raises `FdcResourceNotFoundError` and HTTP 400 raises
`FdcValidationError`. Both classes had been defined, documented, and never
raised by anything.
- **Only `DriType.RDA` ever returned data.** `ul.json` ships real Tolerable
Upper Intake Levels, but under a different schema than `get_dri` knew how to
read, so every UL lookup came back `None`. Both schemas are now understood.
`DriType.AI`, `EAR` and `AMDR` ship no data at all; they still return `None`,
but now log a warning saying so once, rather than leaving an empty DRI column
unexplained.
- **DRI percentages ignored units.** The shipped data does not use one scale
throughout: iron's RDA is `8` mg, its UL is `0.045` **g**. `analyze_food`
divided a food's amount by the DRI as a bare number, so a serving of spinach
measured against iron's upper limit would have reported **2802%** of it
instead of 2.8%. Amounts are now converted into the DRI's unit first, and a
pair that cannot be compared at all — vitamin A in IU against a µg allowance —
yields no percentage rather than a confident wrong one.

## [0.1.11] - 2026-07-13

### Security
Expand Down
59 changes: 34 additions & 25 deletions docs/user/error_handling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ Exception Hierarchy

The library defines the following exception hierarchy:

- ``FdcApiError``: Base exception for all API errors
- ``FdcAuthError``: Authentication failed (invalid API key)
- ``FdcRateLimitError``: API rate limit exceeded
- ``FdcApiError``: Base exception for all API errors (HTTP 5xx and anything unmapped)
- ``FdcAuthError``: Authentication failed — HTTP 401 or 403. FDC sits behind
api.data.gov, which rejects an invalid key with **403**, so both are treated
as an auth failure.
- ``FdcRateLimitError``: API rate limit exceeded — HTTP 429
- ``FdcTimeoutError``: The API did not respond within the client timeout
- ``FdcValidationError``: Invalid input parameters
- ``FdcResourceNotFoundError``: Requested resource not found
- ``FdcValidationError``: The API rejected the request — HTTP 400, typically a
parameter outside the range FDC accepts (``page_size`` above 200, say)
- ``FdcResourceNotFoundError``: Requested resource not found — HTTP 404. A food
that does not exist is an ordinary outcome of a lookup, not a breakdown, so
it is worth catching on its own.

Every one of these is an ``FdcApiError``, so a broad ``except FdcApiError`` still
catches the lot.

Request Timeouts
----------------
Expand Down Expand Up @@ -63,28 +71,27 @@ Here's how to handle errors when using the client:
Handling Specific HTTP Status Codes
--------------------------------

The ``FdcApiError`` exception includes the HTTP status code, which you can use for more specific error handling:
Every ``FdcApiError`` carries the HTTP status the API replied with, as
``status_code``. It is ``None`` for failures that never reached the API — a
refused connection, a timeout — which is itself worth knowing:

.. code-block:: python

from usda_fdc import FdcClient, FdcApiError
from usda_fdc import FdcClient, FdcApiError, FdcResourceNotFoundError

client = FdcClient(api_key="your_api_key_here")

try:
food = client.get_food(1750340)
except FdcResourceNotFoundError:
print("No such food") # usually clearer than reading the status
except FdcApiError as e:
if hasattr(e, 'status_code'):
if e.status_code == 404:
print("Food not found")
elif e.status_code == 429:
print("Too many requests. Try again later.")
elif e.status_code >= 500:
print("Server error. Try again later.")
else:
print(f"API error: {e}")
if e.status_code is None:
print(f"Never reached the API: {e}")
elif e.status_code >= 500:
print("Server error. Try again later.")
else:
print(f"API error without status code: {e}")
print(f"API error {e.status_code}: {e}")

Retry Logic
---------
Expand All @@ -94,25 +101,26 @@ For transient errors like rate limiting or server errors, you can implement retr
.. code-block:: python

import time
from usda_fdc import FdcClient, FdcApiError, FdcRateLimitError
from usda_fdc import FdcClient, FdcApiError, FdcRateLimitError, FdcTimeoutError

client = FdcClient(api_key="your_api_key_here")

def get_food_with_retry(fdc_id, max_retries=3, retry_delay=5):
retries = 0
while retries < max_retries:
try:
return client.get_food(fdc_id)
except FdcRateLimitError:
except (FdcRateLimitError, FdcTimeoutError):
# Both are "try again", not "you asked for the wrong thing"
retries += 1
if retries < max_retries:
print(f"Rate limit exceeded. Retrying in {retry_delay} seconds...")
print(f"Throttled or timed out. Retrying in {retry_delay} seconds...")
time.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff
else:
raise
except FdcApiError as e:
if hasattr(e, 'status_code') and e.status_code >= 500:
if e.status_code is not None and e.status_code >= 500:
retries += 1
if retries < max_retries:
print(f"Server error. Retrying in {retry_delay} seconds...")
Expand All @@ -121,6 +129,7 @@ For transient errors like rate limiting or server errors, you can implement retr
else:
raise
else:
# A 400 or a 404 will not fix itself
raise

# Use the retry function
Expand Down
37 changes: 27 additions & 10 deletions docs/user/nutrient_analysis.rst
Original file line number Diff line number Diff line change
Expand Up @@ -175,29 +175,46 @@ The HTML report includes:
Dietary Reference Intakes (DRIs)
-----------------------------

The library includes data for various types of Dietary Reference Intakes:
The library ships Dietary Reference Intake data drawn from the Institute of
Medicine's *Dietary Reference Intakes: The Essential Guide to Nutrient
Requirements* (2006).

.. code-block:: python

from usda_fdc.analysis.dri import get_dri, DriType, Gender
from usda_fdc.analysis.dri import get_dri_value, DriType, Gender

# Get the RDA for protein for a 30-year-old male
protein_rda = get_dri(
protein_rda = get_dri_value(
nutrient_id="protein",
dri_type=DriType.RDA,
gender=Gender.MALE,
age=30
)

print(f"Protein RDA: {protein_rda}g")

print(f"Protein RDA: {protein_rda.value}{protein_rda.unit}") # 56g

Mind the unit. ``get_dri_value`` returns it alongside the number because the
underlying data does not use one scale throughout: iron's RDA is ``8`` **mg**
while its UL is ``0.045`` **g**. Comparing a food's milligrams against the
latter would overstate it a thousandfold. ``analyze_food`` converts the food's
amount into the DRI's unit before working out ``dri_percent``, and reports no
percentage at all where the two cannot be compared — vitamin A in IU against a
µg allowance, for instance.

``get_dri`` still returns the bare number, in whatever unit that DRI type's data
uses.

Available DRI types:

- ``DriType.RDA``: Recommended Dietary Allowance
- ``DriType.AI``: Adequate Intake
- ``DriType.UL``: Tolerable Upper Intake Level
- ``DriType.EAR``: Estimated Average Requirement
- ``DriType.AMDR``: Acceptable Macronutrient Distribution Range
- ``DriType.RDA``: Recommended Dietary Allowance — **data included**
- ``DriType.UL``: Tolerable Upper Intake Level — **data included**
- ``DriType.AI``: Adequate Intake — no data; lookups return ``None``
- ``DriType.EAR``: Estimated Average Requirement — no data; lookups return ``None``
- ``DriType.AMDR``: Acceptable Macronutrient Distribution Range — no data; lookups return ``None``

Asking for a type with no data behind it logs a warning once and returns
``None``, so an empty DRI column has a stated reason rather than being a
mystery.

Command-Line Interface
-------------------
Expand Down
116 changes: 116 additions & 0 deletions tests/unit/test_dri.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
Tests for DRI lookup and the unit arithmetic around it.

Two schemas ship in this package. rda.json keys nutrients directly and states a
natural unit for each (iron in mg). ul.json, rda_male.json and rda_female.json
wrap a flat table in metadata and express everything in grams (iron 0.045 = 45
mg). Only the first was ever read, so every DriType except RDA silently returned
None — and a gram-denominated DRI compared against a food's milligrams would be
off by a factor of a thousand.
"""

import pytest

from usda_fdc.models import Food, Nutrient
from usda_fdc.analysis import analyze_food
from usda_fdc.analysis.dri import DriType, Gender, get_dri, get_dri_value


def _food_with(nutrient: Nutrient) -> Food:
return Food(fdc_id=1, description="Spinach, baby", data_type="Foundation",
nutrients=[nutrient])


IRON_MG = Nutrient(id=1089, name="Iron, Fe", amount=1.261, unit_name="mg", nutrient_nbr="303")


def test_rda_is_found_with_its_unit():
dri = get_dri_value("iron", DriType.RDA, Gender.MALE, 30)

assert dri.value == 8
assert dri.unit == "mg"


def test_upper_intake_limits_are_found():
"""The regression: ul.json ships real data, but its schema was never read,
so every UL lookup came back None."""
dri = get_dri_value("iron", DriType.UL, Gender.MALE, 30)

assert dri is not None
assert dri.value == 0.045
assert dri.unit == "g" # this file is grams throughout: 45 mg


def test_upper_intake_limits_apply_to_both_genders():
"""ul.json declares gender "all"."""
for gender in (Gender.MALE, Gender.FEMALE):
assert get_dri_value("calcium", DriType.UL, gender, 30) is not None


def test_a_dri_type_with_no_data_returns_none_rather_than_guessing():
assert get_dri_value("iron", DriType.AI, Gender.MALE, 30) is None
assert get_dri_value("iron", DriType.EAR, Gender.MALE, 30) is None


def test_a_missing_dri_type_is_reported_once(caplog):
"""Silence was the bug: every AI lookup returned None with no hint why."""
from usda_fdc.analysis import dri as dri_module
dri_module._warned_missing.discard(DriType.AI.value)
dri_module._dri_cache.pop(DriType.AI.value, None)

with caplog.at_level("WARNING"):
get_dri_value("iron", DriType.AI, Gender.MALE, 30)
get_dri_value("calcium", DriType.AI, Gender.MALE, 30)

warnings = [r for r in caplog.records if "No DRI data" in r.message]
assert len(warnings) == 1


def test_rda_percentage_is_computed_against_the_rda_unit():
analysis = analyze_food(_food_with(IRON_MG), serving_size=100.0, dri_type=DriType.RDA)

iron = analysis.get_nutrient("iron")
assert iron.dri_percent == pytest.approx(1.261 / 8 * 100, rel=1e-6)


def test_a_gram_denominated_dri_is_not_compared_against_milligrams():
"""The unit trap: iron's UL is 0.045 **g**. Dividing 1.261 mg by 0.045
straight would report 2802% of the upper limit for a portion of spinach.
The real answer is 1.261 mg / 45 mg."""
analysis = analyze_food(_food_with(IRON_MG), serving_size=100.0, dri_type=DriType.UL)

iron = analysis.get_nutrient("iron")
assert iron.dri_percent == pytest.approx(1.261 / 45.0 * 100, rel=1e-6)
assert iron.dri_percent < 5.0


def test_the_dri_unit_is_exposed_alongside_the_value():
analysis = analyze_food(_food_with(IRON_MG), serving_size=100.0, dri_type=DriType.UL)

iron = analysis.get_nutrient("iron")
assert iron.dri == 0.045
assert iron.dri_unit == "g"


def test_incomparable_units_yield_no_percentage_rather_than_a_wrong_one():
"""Vitamin A in IU cannot be checked against a µg allowance. Reporting
nothing beats reporting a confident wrong number."""
vitamin_a_iu = Nutrient(id=1104, name="Vitamin A, IU", amount=9377.0,
unit_name="IU", nutrient_nbr="318")

analysis = analyze_food(_food_with(vitamin_a_iu), serving_size=100.0)

vitamin_a = analysis.get_nutrient("vitamin_a")
assert vitamin_a.dri_percent is None


def test_get_dri_still_returns_a_bare_number():
"""The old signature stays, for callers already using it."""
assert get_dri("iron", DriType.RDA, Gender.MALE, 30) == 8


def test_age_and_gender_still_select_the_right_rda():
"""Iron: 18 mg for women of 19-50, 8 mg after."""
assert get_dri("iron", DriType.RDA, Gender.FEMALE, 30) == 18
assert get_dri("iron", DriType.RDA, Gender.FEMALE, 60) == 8
assert get_dri("iron", DriType.RDA, Gender.MALE, 30) == 8
Loading
Loading