Skip to content

Commit 1cd04b7

Browse files
gutzbenjclaude
andauthored
fix(restapi): a reader missing on the server is a 501, not the caller's fault (#1913)
A deployment installed without the `bufr` extra answered a well-formed request for a BUFR network with `400 Bad Request` and a body reading `pip install wetterdienst[bufr]` -- an instruction for a machine the caller does not administer, about a request they phrased correctly. `interpolate` and `summarize` were worse: their blanket handler called it a 404, which reads as "no such network". The missing half is a property of the deployment, so 501 is the honest answer: this server does not implement the networks published as BUFR. The body says that and nothing the caller cannot act on; the install line moves to the server log, where whoever runs the instance reads it. This is the other half of #1910, which gave the CLI a handler for `BufrReaderMissingError` and left the REST API on the blanket one. The MCP server rides on the same app, so it answers the same way now. The values endpoint gained a third `except` and tripped `C901`, so its collection moves into `_values`, beside the `_geo_values` the other two endpoints already share. Same reason as that one: which failure earns which status is a decision of its own. The three new tests fail against the old handlers with 400, 404 and 404. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4271215 commit 1cd04b7

3 files changed

Lines changed: 142 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ Types of changes:
3131

3232
### Fixed
3333

34+
- REST API: a BUFR reader missing on the server answers 501 rather than 400. The blanket handler
35+
read every failure as the caller's, so a deployment installed without the `bufr` extra told the
36+
client to `pip install wetterdienst[bufr]` on a machine they do not administer, for a request
37+
that was perfectly well formed -- and `interpolate` and `summarize` called the same thing a 404,
38+
which reads as "no such network". The install line moves to the server log, where whoever runs
39+
the instance can act on it
3440
- CI: the test and coverage workflows watch `examples/**`. `tests/examples` runs those files, so a
3541
change to one is a change both suites cover -- and a pull request touching only an example ran
3642
neither, while the coverage workflow's header said it takes the same inputs as the test matrix

src/wetterdienst/ui/restapi.py

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from pydantic import ValidationError
1515

1616
from wetterdienst import Author, Info, Settings, Wetterdienst, __version__
17-
from wetterdienst.exceptions import ApiNotFoundError, NoStationsWithHeightError, StartDateEndDateError
17+
from wetterdienst.exceptions import (
18+
ApiNotFoundError,
19+
BufrReaderMissingError,
20+
NoStationsWithHeightError,
21+
StartDateEndDateError,
22+
)
1823

1924
# needed at runtime: FastAPI resolves this annotation to build the query parameter's enum
2025
from wetterdienst.metadata.unit_type import UnitType # noqa: TC001
@@ -56,7 +61,7 @@
5661
from collections.abc import Callable
5762

5863
from wetterdienst.model.request import TimeseriesRequest
59-
from wetterdienst.model.result import InterpolatedValuesResult, SummarizedValuesResult
64+
from wetterdienst.model.result import InterpolatedValuesResult, SummarizedValuesResult, ValuesResult
6065

6166
info = Info()
6267

@@ -84,6 +89,28 @@
8489
}
8590

8691

92+
def _reader_missing_on_the_server(e: BufrReaderMissingError, what: str) -> HTTPException:
93+
"""Report a reader this deployment does not have as the server's lack, not the caller's error.
94+
95+
The blanket handlers answered it with a 400 carrying `pip install wetterdienst[bufr]` -- an
96+
instruction for a machine the caller does not administer, about a request that was perfectly
97+
well formed. Over HTTP the missing half is a property of the deployment, and 501 is what says
98+
so: this server does not implement the networks published as BUFR. The install line is not
99+
lost, it moves to where someone can act on it -- the server log, carried there by the message
100+
itself rather than by a traceback, since a dependency that was never installed has no incident
101+
to show.
102+
"""
103+
log.error(f"Failed to {what}, this deployment cannot decode BUFR: {e}")
104+
return HTTPException(
105+
status_code=501,
106+
detail=(
107+
"This server cannot decode BUFR, which the requested network is published as. The "
108+
"request was valid; the deployment is missing the eccodes and pdbufr readers that "
109+
"read it. Ask whoever runs this instance to install them."
110+
),
111+
)
112+
113+
87114
@app.get("/")
88115
def index() -> HTMLResponse:
89116
"""Provide index page."""
@@ -540,21 +567,7 @@ def values(
540567
ts_drop_nulls=request.drop_nulls,
541568
)
542569

543-
try:
544-
values_ = get_values(
545-
api=api,
546-
request=request,
547-
settings=settings,
548-
)
549-
except StartDateEndDateError as e:
550-
log.exception("Failed to get values.")
551-
raise HTTPException(
552-
status_code=400,
553-
detail=str(e),
554-
) from e
555-
except Exception as e:
556-
log.exception("Failed to get values.")
557-
raise HTTPException(status_code=400, detail=str(e)) from e
570+
values_ = _values(api=api, request=request, settings=settings)
558571

559572
# build kwargs dynamically
560573
kwargs: dict[str, Any] = {
@@ -609,6 +622,29 @@ def _geo_settings(
609622
raise HTTPException(status_code=400, detail=str(e)) from e
610623

611624

625+
def _values(
626+
api: type[TimeseriesRequest],
627+
request: ValuesRequest,
628+
settings: Settings,
629+
) -> ValuesResult:
630+
"""Collect values, telling the caller which failures are theirs to fix.
631+
632+
The sibling of `_geo_values` for the plain values endpoint, and lifted out of the endpoint for
633+
the same reason: which failure earns which status is a decision of its own, and the endpoint --
634+
which also assembles a response format, a target and a set of kwargs -- is not where it belongs.
635+
"""
636+
try:
637+
return get_values(api=api, request=request, settings=settings)
638+
except StartDateEndDateError as e:
639+
log.exception("Failed to get values.")
640+
raise HTTPException(status_code=400, detail=str(e)) from e
641+
except BufrReaderMissingError as e:
642+
raise _reader_missing_on_the_server(e, "get values") from e
643+
except Exception as e:
644+
log.exception("Failed to get values.")
645+
raise HTTPException(status_code=400, detail=str(e)) from e
646+
647+
612648
def _geo_values(
613649
get: Callable[..., InterpolatedValuesResult | SummarizedValuesResult],
614650
api: type[TimeseriesRequest],
@@ -620,8 +656,9 @@ def _geo_values(
620656
621657
Both endpoints answered every failure with a 404, which reads as "no such thing" for a request
622658
that was understood and simply cannot be served as phrased -- an elevation no station in reach
623-
can be placed against, or a window that ends before it starts. Those are 400s, and the same two
624-
in both places, so they are decided here rather than twice over.
659+
can be placed against, or a window that ends before it starts. Those are 400s, and a reader
660+
missing on the server is a 501; the same three in both places, so they are decided here rather
661+
than twice over.
625662
"""
626663
try:
627664
return get(api=api, request=request, settings=settings)
@@ -633,6 +670,8 @@ def _geo_values(
633670
except StartDateEndDateError as e:
634671
log.exception(f"Failed to {what}")
635672
raise HTTPException(status_code=400, detail=str(e)) from e
673+
except BufrReaderMissingError as e:
674+
raise _reader_missing_on_the_server(e, what) from e
636675
except Exception as e:
637676
log.exception(f"Failed to {what}")
638677
raise HTTPException(status_code=404, detail=str(e)) from e

tests/ui/test_restapi.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""Tests for the REST API."""
44

55
import json
6+
import logging
67

78
import pytest
89
from dirty_equals import IsApprox, IsNumber, IsStr
@@ -2293,3 +2294,80 @@ def unanswerable(**_kwargs: object) -> None:
22932294
)
22942295
assert response.status_code == 400
22952296
assert response.json()["detail"] == msg
2297+
2298+
2299+
@pytest.mark.parametrize(
2300+
("endpoint", "entry_point", "params"),
2301+
[
2302+
(
2303+
"/api/values",
2304+
"get_values",
2305+
{
2306+
"provider": "dwd",
2307+
"network": "road",
2308+
"parameters": "15_minutes/data/temperature_air_mean_2m",
2309+
"station": "A006",
2310+
"date": "2024-01-01/2024-01-02",
2311+
},
2312+
),
2313+
(
2314+
"/api/interpolate",
2315+
"get_interpolate",
2316+
{
2317+
"provider": "dwd",
2318+
"network": "road",
2319+
"parameters": "15_minutes/data/temperature_air_mean_2m",
2320+
"station": "A006",
2321+
"date": "2024-01-01",
2322+
},
2323+
),
2324+
(
2325+
"/api/summarize",
2326+
"get_summarize",
2327+
{
2328+
"provider": "dwd",
2329+
"network": "road",
2330+
"parameters": "15_minutes/data/temperature_air_mean_2m",
2331+
"station": "A006",
2332+
"date": "2024-01-01",
2333+
},
2334+
),
2335+
],
2336+
)
2337+
def test_a_reader_missing_on_the_server_is_a_501(
2338+
client: TestClient,
2339+
monkeypatch: pytest.MonkeyPatch,
2340+
caplog: pytest.LogCaptureFixture,
2341+
endpoint: str,
2342+
entry_point: str,
2343+
params: dict[str, str],
2344+
) -> None:
2345+
"""A reader the deployment lacks is the server's lack, and it says so with the right status.
2346+
2347+
The blanket handler turned it into a 400 -- the caller's fault -- carrying `pip install
2348+
wetterdienst[bufr]`, which is an instruction for a machine the caller does not administer. The
2349+
request was well formed; it is this instance that cannot serve it, which is a 501.
2350+
2351+
Raised from a stubbed getter rather than by asking DWD for a road station: what is under test
2352+
is the status and the body, and the real path downloads a station list on the way to the error.
2353+
"""
2354+
from wetterdienst.exceptions import BufrReaderMissingError # noqa: PLC0415
2355+
2356+
msg = (
2357+
"DWD road weather data is published as BUFR, which needs eccodes and pdbufr to read: "
2358+
"`pip install wetterdienst[bufr]` installs both."
2359+
)
2360+
2361+
def refuse(**_kwargs: object) -> None:
2362+
raise BufrReaderMissingError(msg)
2363+
2364+
monkeypatch.setattr(f"wetterdienst.ui.restapi.{entry_point}", refuse)
2365+
with caplog.at_level(logging.ERROR):
2366+
response = client.get(endpoint, params=params)
2367+
2368+
assert response.status_code == 501
2369+
detail = response.json()["detail"]
2370+
assert "eccodes and pdbufr" in detail
2371+
# the install line is for whoever runs the instance, and reaches them through the log
2372+
assert "pip install" not in detail
2373+
assert "pip install wetterdienst[bufr]" in caplog.text

0 commit comments

Comments
 (0)