The first sign was a parse error, which is the wrong first sign.
Our invoicing system pulls the National Bank of Romania reference rate every morning, because an invoice issued in euro has to be converted into lei at a rate somebody can point to later. One morning the job logged not well-formed (invalid token) at line 1, column 1. That message says one thing to anyone who has ever written a parser: the XML changed. Maybe they added a namespace. Maybe the encoding moved.
It was not the XML. There was no XML.
What was actually happening
BNR rebuilt their website in August 2026. The old feed paths went with it. Here is what they do now, checked the day this was written:
$ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" \
https://www.bnr.ro/nbrfxrates.xml
302 -> https://www.bnr.ro/
$ curl -sL https://www.bnr.ro/nbrfxrates.xml | head -c 60
<!doctype html><html lang="ro">
A 302 to the homepage. The homepage returns 200. So the request succeeded, by every measure a normal HTTP client cares about, and handed our parser a page of Romanian marketing copy where a currency table used to be.
The status code said yes. The content type said text/html. Only the parser noticed, and the parser can only report what it sees, which is that the first character is a < in the wrong place.
Why this is the worst shape a failure can take
A 404 is information. It is unambiguous, it is machine-readable, and every monitoring system on earth already has an opinion about it. You get paged, you look, you fix the URL. The whole incident is twenty minutes.
A 200 carrying the wrong thing is different. It defeats uptime checks, because the endpoint is up. It defeats status-code alerting, because the status is fine. It defeats the retry logic, because retries of a successful request succeed. Everything downstream of the fetch is working perfectly and producing garbage, and the only component that objects is the one furthest from the cause.
And there is a nastier version of this failure than the one we got. We were lucky: our parser was strict, so it threw. A more forgiving parser, or a try/except that shrugged and returned the last known value, would have kept the system running on a rate that quietly stopped moving. Nobody would have looked for months. The first person to notice would have been an accountant, reconciling a quarter, wondering why every euro invoice since August used the same figure.
That is the real risk here, and it is not a Romanian one. It is what happens whenever you treat "the request worked" as "the data is good".
The three assertions people collapse into one
Fetching remote data makes three separate claims, and most code checks only the first.
The request succeeded. status < 400. This is the one everybody checks, and on its own it is nearly worthless, because the modern web answers 200 to almost everything. Soft 404s are not an edge case, they are the norm.
The response is the right kind of thing. Content type, root element, a known marker somewhere in the first few hundred bytes. Cheap to assert and it would have caught this on day one, with an error message naming the actual problem instead of a column number.
The data is fresh. This is the one that matters most and gets checked least. A feed can return valid, well-formed, correctly-typed content that has not changed since March. Parsing succeeds. Validation succeeds. The number is wrong.
So the check we run now is not "did the fetch work". It is: does the payload contain a rate dated within the last four days, and is that date the one we expected. Everything else falls out of that. A dead endpoint fails it. A stale feed fails it. A redesign that moves the URL fails it. A silent schema change that drops the date field fails it, loudly, on the day it happens.
The four-day window, and why it is not three
Here is the part that catches people who build this from a spec rather than from a calendar.
BNR publishes reference rates on working days. Not weekends. Not public holidays, of which Romania has a generous number, several of which move with Orthodox Easter and therefore cannot be hard-coded as a list of dates without an expiry.
So "yesterday's rate" is wrong roughly a hundred days a year. On a Monday, yesterday was Sunday, and there is no Sunday rate. Over the Easter weekend, or the first of December, or the stretch around Christmas, the gap can run to four days. Any freshness check tighter than that will page somebody at 09:00 on a bank holiday to report that the National Bank is closed, which it is, and which is not an incident.
Get this wrong in the other direction and the alert never fires. Set the window to seven days to stop the false alarms and you have built a monitor that tolerates a feed being a week stale, which is exactly the failure you were trying to catch.
The correct logic is not a window at all. It is: walk backwards from today to the most recent working day, and expect a rate for that day. Then the check has no tolerance to tune, and holidays are the only thing you have to know about.
What we changed
The fetch now asserts, in order: the response is XML by content type, the root element is the one we expect, there is a rate entry whose date is the most recent Romanian working day, and the currencies we actually invoice in are all present. Any of those failing raises with a message that names which one, because "invalid token at line 1" cost an afternoon that "expected XML, got text/html after a redirect to /" would have cost ten minutes.
The alert is on absence, not on error. A job that does not report success by 14:00 is a problem whether it crashed, hung, or cheerfully parsed a homepage.
And the last known rate is stored with the date it was published for, never as a bare number. A value with a date attached can be checked. A value on its own is an assumption wearing a number's clothes.
The general form
Every integration you do not own is a URL that somebody else can redesign on a Tuesday without telling you. That is not carelessness on their part. BNR do not know you exist, and the reference rate page is a public service, not an API with a deprecation policy and a support contract.
Which means the burden is entirely yours, and it is not the burden people expect. The hard part is never parsing the data. It is noticing, quickly, when what you are parsing stopped being the data.
We write these integrations for a living, mostly the Romanian compliance ones: rates, e-Factura, bank statements, the parts of a system that talk to institutions rather than to people. Every one of them has a version of this story. The details change. The shape does not.
