connection refused is not a diagnosis. It’s a location marker. The component that caught the error is telling you where it stands, not where the failure started.
Error messages are written by the component that catches the exception, not the component that caused it. A TCP stack that receives a RST knows it tried to connect and was refused. It doesn’t know whether the target service crashed, the port is wrong, the firewall blocked it, the deployment didn’t finish, or DNS resolved to the wrong IP. Each of those is a different fix. The error message provides exactly enough information to locate the failure and not enough to diagnose it.
This is worse than no message in one specific way: it looks like information. An engineer reads connection refused, concludes “service is down,” restarts the service, and watches it fail again. The actual problem was the firewall rule that got changed in the last deploy. The error message pattern-matched to a familiar diagnosis. The diagnosis was wrong. The message didn’t lie. It was enough information to be convincing and not enough to be useful.
The error surface is flattened by the boundary. The common case for connection refused probably is “service is down,” and the message is calibrated for that. The edge cases – wrong port, firewall, incomplete deploy – get the same message because the component genuinely can’t tell them apart. The boundary strips the context that would distinguish them.
Three more, because the pattern repeats.
context deadline exceeded. What it tells you: a deadline was set and the operation didn’t finish in time. What you conclude: the downstream is too slow, the timeout is too tight, or the operation is too heavy. What it doesn’t tell you: whether the downstream received the request at all, whether it’s still working on it, whether it finished and the response was dropped, whether the deadline was reached because the local clock drifted. The deadline is local. The downstream doesn’t know about it. The error reports a timeout on this side without knowing anything about what happened on the other side. You can retry the request and the downstream might process it twice, because the first one was still in flight when the deadline fired.
nil pointer dereference. What it tells you: a pointer was nil where a value was expected. What you conclude: someone forgot to check for nil. What it doesn’t tell you: how the nil got there. Was it a missing initialization? A function that returned nil on error and the error was discarded? A field that’s populated conditionally and the condition wasn’t met in this code path? A map lookup that returned the zero value? Each of those is a different code path, a different fix, and possibly a different file. The stack trace points at the dereference site. The nil entered somewhere else, possibly several stack frames away, possibly in a different service that serialized the value and sent it over the wire.
unexpected EOF. What it tells you: the reader expected more bytes and got none. What you conclude: the connection dropped, the file was truncated, or the writer crashed mid-stream. What it doesn’t tell you: which of those happened, or why. A truncated file on disk, a killed process writing to a pipe, a TCP connection that received a FIN mid-message, a client that sent a Content-Length header that didn’t match the body, a timeout on the read side that closed the socket. For a log line to distinguish these, the reader would need to know the writer’s exit status, the transport state, the protocol framing, and the intent of the bytes that were supposed to arrive. It has access to none of that. It has the bytes it received and the fact that they stopped.
The same shape in all four: a component at one point in the system reports what it observed. It cannot report anything it didn’t observe, and what it didn’t observe is everything upstream of itself. The error message is the component’s local view of a distributed failure. The local view is always correct. It is also always insufficient.
This is not a bug in error messages. It’s a property of boundaries. The component that catches the error is, by definition, on one side of a boundary. The cause of the error is, often, on the other side. The boundary exists precisely to separate the two sides – to encapsulate, to abstract, to keep concerns apart. The same boundary that makes the system designable also makes the failures undiagnosable from one side alone.
What would an error message need to know to be genuinely diagnostic? It would need to know the caller’s intent (what were you trying to do), the system’s state along the path (what did each hop see), and the history that led to this point (what changed recently). All three of those are on the other side of a boundary from the component that caught the error. The error message can’t hold them because the architecture was designed to keep them apart.
That’s not an error message. That’s a trace. Which is why distributed tracing exists.
A trace is the protocol for making the error message’s implicit claim true. The error says “here’s what happened, here’s where it happened.” A trace that spans the request from origin to failure can actually say where it happened, because it has context from both sides of each boundary. It knows the caller’s intent (the parent span), the path through the system (the span chain), and often what changed recently (timestamps, version tags, deploy markers). The trace fills in the fields the error message structurally cannot hold.
connection refused stops being “the service is down” and becomes “the TCP handshake didn’t complete, and every component involved in completing it is now a candidate.” The first frame that fits is usually the wrong frame. The message can see some things and not others. The gap between what it can see and what it can’t is where the diagnosis lives.
A stack trace is not an explanation. It’s a starting location. The explanation lives on the other side of a boundary the stack trace doesn’t cross.
