DEV Community

Mohammed Arshad Ansari
Mohammed Arshad Ansari

Posted on

The most dangerous API response is HTTP 200 with an empty body

Every pipeline eventually inherits a source that moves. Ours did: dataservices.imf.org
stopped resolving in DNS entirely — not a 500, not a timeout, the hostname itself was gone.
The IMF had migrated its data platform and the old host was on its way out. No villain in
this story; following a publisher to its new home is the consumer's job.

The interesting part is the failure mode the migration exposed, which has nothing to do with
the IMF and everything to do with how most of us write ingestion code.

The symptom

Monthly CPI for a set of non-OECD countries went quietly stale, frozen at December 2025, while
every other source kept ticking. Nothing screamed. The daily job ran green. Downstream scoring
kept computing on last-known values, because CPI resolves through a fallback chain — if the
freshest monthly print is unavailable, the model reaches for the next-best source rather than
failing outright.

That fallback is a feature: one dead source should not take scoring down for a whole tier of
countries. It also has a cost. Graceful degradation and silent staleness are the same
mechanism viewed from two angles.
A freshness dashboard is what keeps the second angle
visible; without one, the design that saves you also hides the problem from you.

The migration itself

The new home is api.imf.org, on SDMX 2.1, and it still speaks the same StructureSpecific XML
dialect — so the parser barely changed. All the pain was in the keys:

  • CPI now lives on the IMF.STA,CPI dataflow, and the all-items series is keyed with COICOP _T (the SDMX "total" convention), not the CP00 the old platform used.
  • Balance-of-payments moved to IMF.STA,BOP, where the legacy indicator codes do not port one-to-one. No find-and-replace; each series had to be re-derived against the new structure definition and re-tested.

Tedious rather than clever — and easy to get subtly wrong, because of what a wrong key does.

The gotcha worth stealing

A bad key returns HTTP 200 with an empty DataSet.

Not a 404. Not a 400. A cheerful 200 OK, a well-formed SDMX document, zero observations
inside it. If your ingestion trusts the status line:

resp = await client.get(url)
if resp.status_code == 200:          # <- the bug
    rows = parse(resp.text)
    await store(rows)                # stores nothing, reports success
Enter fullscreen mode Exit fullscreen mode

…then a typo in a dimension code, or an un-migrated series key, sails through as success and
writes nothing. Job green. Data frozen. You find out weeks later from a freshness alert, if
you have one, or from a customer, if you do not.

Our fix is a canary: the request always carries a segment we know must return data — United
States all-items CPI — and a parse that yields zero series therefore cannot mean "no data".

series = parse_sdmx21_data(xml_bytes)
if not series:
    raise IMFIFSUnavailableError(
        "IMF.STA,CPI returned an empty DataSet incl. the USA canary — "
        "key schema change or platform fault"
    )
Enter fullscreen mode Exit fullscreen mode

The USA rows are then filtered out before storage — its index base differs from our other
US source and double-sourcing would corrupt the derived series. The canary lives in the
request, not in the warehouse.

Three lines, and it is the difference between finding a structural break in one run versus one
month. Having built it for CPI, it generalises to every SDMX-style source that can return a
well-formed empty response: pick a segment that must exist, assert it came back populated, let
a broken key contract throw. Note what it is not for: a genuine upstream outage is a
different event, and that one deliberately does not raise — it materialises with an
imf_ifs_outage flag, because failing hard there would skip every downstream scoring asset in
the same run.

The same bug one layer up: a fetch that succeeds is not a document

The identical mistake, in a different shape, was sitting in our central-bank statement
scrapers. They fetched a listing page, followed a link, extracted text, and handed it to an
LLM for a hawkish/dovish read. Every step returned 200. Every step "worked".

What was actually being scored, once we read the stored text back:

  • a bank's login form, because its statement listing redirects to /login
  • a press-release index whose top headline was Treasury-bill auction results
  • a SharePoint "you may be trying to access this site from a secured browser" notice
  • 115 characters of "this page depends on JavaScript"
  • a rates statistics nav link, because the link pattern interest.*rate matched it and it sorted first in the DOM

The parallel to the empty DataSet is exact: nothing failed, so nothing raised, so a
plausible-looking value went downstream. The fixes were the same shape as the canary —
assert the content, not the transport:

  1. A length floor and a bot/JS-notice check, so a page that cannot be a statement is skipped rather than scored.
  2. Feeds and APIs instead of HTML scraping where the bank publishes one (RSS/Atom, or the PDF-minutes API where the web page is a JS shell).
  3. A source is only enabled once its extracted text has been read back and confirmed to be a monetary policy decision — and a source that cannot pass is switched off in config with the reason recorded, not left nominally "covered". Ten of seventeen configured banks are on; seven are off behind a login wall, a client-rendered listing, or commercial bot management we decline to work around.

Two reusable lessons

  1. Assert data presence, not status codes. 200 OK means the HTTP conversation succeeded. It says nothing about whether the payload contains what you asked for.
  2. Connection outages and silent-empty responses are different failure modes needing different handling. A DNS failure throws — retry with backoff and you will know. A 200-plus-empty never throws, so try/except + retry leaves it completely uncovered. It needs an affirmative presence check. Handle both, separately, on purpose.

The payoff, incidentally, was not just damage control: following the source to its new home
took BOP coverage from roughly 70 to 148 countries and made reserves available quarterly. The
endpoint you are reluctantly forced onto is often the one the publisher is actually investing
in.

Full write-up, with the migration detail:

What the pipeline feeds — a daily 0–100 credibility score for 169 countries on 100% free
public data, with per-indicator provenance:


Top comments (0)