DEV Community

Cover image for The contract is a rumor
Hideki Mori
Hideki Mori

Posted on

The contract is a rumor

Every client I write assumes nothing about the shape of what comes back. If I read a field, I handle the case where it's null. If I read a list, I handle the case where it's empty. I do this for every integration, including the ones that have put a value in that field every single time, for years.

Not because the other side is careless. Because I didn't write the value. The moment a piece of data crosses a boundary I don't control, "it has always been there" stops being a statement about the future and becomes a statement about the past. A field that has never been null is a sample, not a guarantee. So I handle the null. There are no exceptions to this. It's the floor.


More than the floor

Most of the time the floor never triggers. The field is present, the list is full, the call succeeds, and all that defensive code sits unused like a smoke detector. Good. That's what it's for.

But some dependencies ask for more than the floor — not more handling of a good response's shape, but handling of the fact that the response lies about itself.

I have integrated an API that returns HTTP 200 for everything, its own failures included. To learn whether the call actually worked you parse an integer out of the body — and that integer means different things depending on which deployment you're talking to, and it has quietly changed meaning from one date to another. For a large enough request the same endpoint sometimes answers in XML instead of JSON, and you scrape the status out of that instead. The transport already has a field for "did this work." The API declines to use it.

I have integrated an API that returns its results as two separate lists — the ones that succeeded and the ones that failed — in no guaranteed order, to be reconciled against your request by id. That one is not a sin; serious platforms do it, and if you expect it, it's clean. But it punishes the obvious reading. Match by position instead of by id and you will, eventually, hand someone the wrong answer with complete confidence.

I have integrated an API whose job state could run backwards: a job that was further along could fall back to an earlier state mid-flight, so "queued," "processing," and "done" were not the monotonic staircase the words implied.

And I have integrated the other kind — the kind that reinvents a convention that already exists, inconsistently. Language codes that aren't language codes: one letter per language, concatenated, with a constant letter stuck on the end, so an ordinary pair becomes a three-character string you would never guess. Error messages written in a human language, as prose, for a human to read, inside an interface only a program will ever call. And no endpoint to ask what the service supports — so you keep a table of the supported options pasted by hand off a documentation page, with a comment recording the day you last checked it, because that table is the only copy you have, and it rots.

The kind that unsettles me most is none of those. It's the major, reputable API — the one everyone would call well-behaved — that occasionally, silently, returns the wrong thing. You ask for the list of target languages and once in a while it hands you the source list. A field comes back subtly malformed. There is no error. The status is green, the shape is valid, the content is wrong. The only defense is to notice and retry, which means the bad response is invisible twice over: to you, because the retry papers over it, and to the vendor, because you never report the thing you quietly worked around.


None of that is the cost

Here is the part it took me years to say plainly: writing all of that handling is not the cost. You write it once.

The cost is that none of it stays put. The status code you special-cased may have been fixed already — and you won't know, because nothing tells you; you have to go and look. A behavior keyed to a date. A capability that changed between one version of a model and the next. The integration test that passed this morning told you about this morning. It did not tell you about the contract, because there is no stable contract underneath. There is only current behavior, and current behavior is a moving target.

I once wrote that a system of mine is finished when there is nothing I can throw at it that reopens it. That is reachable for code I own. An integration is the mirror image — the system I can never close, because the other side keeps reopening it. My own logic I can make correct. Someone else's behavior I can only watch. The contract is a rumor. The behavior is the fact. And the fact changes while I'm not looking.


Not only on the wire

For a long time I filed this under "APIs" — a property of things at the far end of a network call. It isn't. The dependency is not always an HTTP endpoint.

Some of the worst-behaved things I integrate run inside my own process: a native engine, loaded through a binding, doing heavy work in C. When one of those fails there is no response to inspect, so the failure signal isn't a status code. It might be an exception carrying a cryptic hexadecimal code. A line on standard error. A process that exits with a number that means "aborted." Or a defunct entry in the process table that is never going to return at all. I have written code whose entire job is to watch the process list for a zombie and give up on its behalf.

When the dependency is like that, "make it finish" stops meaning "retry the request." It comes to mean: convert the input into another format, run it through a different engine, then repair the output by passing it through a third — holding a license semaphore the whole way, because even the repair tool is metered. And the drift is the same as on the wire. A library that, in a new major version, will quietly corrupt a file if you read from it while you're writing to it. A document format a parser still won't fully accept, with a bug number you learn to swallow. Behavior that moved between versions, and a dated comment marking the spot where I noticed.

So the subject was never "APIs." It's dependencies. A weird one makes you keep watching it, by whatever instrument it leaves you. Sometimes that instrument is an HTTP status code. Sometimes it's ps.


The ugly client is the point

This is why I am slow to adopt an official SDK when one appears after I've already built the integration by hand.

An SDK encodes what the vendor believes their API does. My hand-written client encodes what it actually did — including the workarounds, and an SDK carries no workaround for behavior its authors don't believe in. Adopting it discards that record and hides the raw responses, which is exactly where the drift becomes visible. The convenience layers that promise to abstract away the differences between providers are the same trade in a larger box: they keep a table of who-supports-what, the table falls behind, and you end up hand-listing the exceptions on top of the abstraction anyway.

And I should be honest about what this code looks like. It is not clean. It is a heap of special cases, string matches against error signatures, fallbacks to other engines, retries, and semaphores. The base classes under it carry compromises I would make differently today. I don't grade any of it on how it reads. I grade it on one thing: whether the class at the very edge — the one actually making the call — holds the next time the dependency does something new. It can be wrestling a dozen ugly truths into submission inside, and that's fine, as long as it doesn't break. The code is allowed to be as ugly as the reality it describes. Making it pretty usually means pretending the reality is prettier than it is.


Two axes

The floor and the watching are the same instinct aimed in two directions.

The floor says: assume nothing about the shape of what comes back. The watching says: assume nothing about the stability of how it behaves. Shape and time. A well-behaved dependency lets you stop watching one axis — the shape is honest, or the behavior holds still. I have never found one that lets you stop watching both.

So the work was never "handle the weird API." It is narrower than that, and more permanent. It is refusing to let "it has worked every time so far" quietly turn into "it will work." That sentence is true of a single null field and it is true of an entire dependency. A run that passes is a sample. It is never a proof.


Built with Claude (Opus).

Top comments (0)