- Book: Thinking in Go — the 2-book series on Go and hexagonal architecture
- Also by me: Observability for LLM Applications · Ebook from Apr 22
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your function can fail. Five languages give you five different contracts for that failure. Four of them let you forget. One of them will not let you. Here is what each actually produces in production traces.
The operation is the same in every language: open a config file, parse it as JSON, call an HTTP endpoint with a value from the config, return a typed result. Every step can fail. Every failure needs to reach your telemetry. Every language handles this differently, and the differences show up in your pager.
flowchart TD
subgraph GO["Go"]
GO1[val, err := f] --> GO2{err != nil?}
GO2 -->|yes| GO3[return err]
GO2 -->|no| GO4[use val]
end
subgraph RS["Rust"]
RS1[let val = f?] --> RS2{Err?}
RS2 -->|yes| RS3[? returns early]
RS2 -->|no| RS4[use val]
end
subgraph PY["Python"]
PY1[try: val = f] --> PY2{raises?}
PY2 -->|yes| PY3[except or<br/>propagates up]
PY2 -->|no| PY4[use val]
end
subgraph JS["JavaScript"]
JS1[try val = f] --> JS2{throws?}
JS2 -->|yes| JS3[catch or<br/>unhandled rejection]
JS2 -->|no| JS4[use val]
end
Go: explicit err, every step, no hiding
Go has no exceptions. A function that can fail returns (T, error) and you check err at the call site. It is verbose. That is the point.
type Config struct {
Endpoint string `json:"endpoint"`
}
type Result struct {
Status int
Body []byte
}
func Fetch(ctx context.Context, path string) (*Result, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", cfg.Endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("call %s: %w", cfg.Endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return &Result{Status: resp.StatusCode, Body: body}, nil
}
Four err checks, four wrapped errors, one stack of context the caller can unwrap. %w preserves the chain so errors.Is and errors.As still work upstream.
What Go gets right: you cannot accidentally ignore a failure. The compiler will flag an unused err. The error path is visible in code review. When this function ends up in a trace, each wrapped message points to the exact line that failed.
What Go gets wrong: the verbosity is real. A 10-line function becomes 25. People propose ?-style sugar every year. It never lands, because removing the visible check is the whole trade-off Go made.
Rust: Result<T, E> and the ? operator
Rust encodes failure in the type system. Result<T, E> is an enum: either Ok(T) or Err(E). You cannot use the value until you unwrap the result, and the compiler will not let you forget.
use serde::Deserialize;
use thiserror::Error;
#[derive(Deserialize)]
struct Config {
endpoint: String,
}
pub struct FetchResult {
pub status: u16,
pub body: String,
}
#[derive(Error, Debug)]
pub enum FetchError {
#[error("read config {path}: {source}")]
Io { path: String, source: std::io::Error },
#[error("parse config: {0}")]
Parse(#[from] serde_json::Error),
#[error("http call failed: {0}")]
Http(#[from] reqwest::Error),
}
pub async fn fetch(path: &str) -> Result<FetchResult, FetchError> {
let data = std::fs::read_to_string(path).map_err(|e| FetchError::Io {
path: path.to_string(),
source: e,
})?;
let cfg: Config = serde_json::from_str(&data)?;
let resp = reqwest::get(&cfg.endpoint).await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
Ok(FetchResult { status, body })
}
The ? operator does the work. If the expression before it returns Err, the function short-circuits and returns that error, converting it via From if needed. No try, no catch, no hidden control flow. The compiler tracks every possible failure, and the error enum is exhaustive.
What Rust gets right: failure is a value. The type system knows every error a function can produce. Pattern-match on the returned Err and the compiler will warn if you forget a variant. There is no path where an error silently escapes your handler.
What Rust gets wrong: the ergonomics tax on the setup. Defining error enums with thiserror, threading From impls, and picking between Box<dyn Error>, anyhow::Error, and a bespoke enum is a recurring cost. Beginners reach for unwrap() and pay later.
Python: try/except and the deep class hierarchy
Python propagates exceptions up the stack until someone catches them. Every operation can raise; you rarely see which one in the signature.
from dataclasses import dataclass
import json
import httpx
@dataclass
class FetchResult:
status: int
body: str
class FetchError(Exception):
"""Anything wrong on the fetch path."""
def fetch(path: str) -> FetchResult:
try:
with open(path) as f:
cfg = json.load(f)
except FileNotFoundError as e:
raise FetchError(f"config not found: {path}") from e
except json.JSONDecodeError as e:
raise FetchError(f"bad config json: {e}") from e
endpoint = cfg.get("endpoint")
if not endpoint:
raise FetchError("config missing 'endpoint'")
try:
resp = httpx.get(endpoint, timeout=10.0)
except httpx.HTTPError as e:
raise FetchError(f"http call failed: {e}") from e
return FetchResult(status=resp.status_code, body=resp.text)
The from e preserves the original cause so tracebacks show both layers. Without it you get a confusing "During handling of the above exception, another exception occurred" chain instead of a clean cause.
What Python gets right: the code stays readable. No marker every two lines. Custom exception classes let you group failures into meaningful categories, and logging.exception will dump the full stack for free.
What Python gets wrong: nothing in the signature tells the caller what can raise. A function typed -> FetchResult can actually raise ConnectionError, TimeoutError, UnicodeDecodeError, a KeyError from a dict miss you forgot about. Your tests pass. Production hits the untyped path six months later and your trace shows an uncaught KeyError bubbling up to the web framework's 500 handler.
Java: checked exceptions plus runtime exceptions
Java separates failures into two worlds. Checked exceptions (IOException, custom checked types) must appear in the method signature or be caught. Unchecked exceptions (RuntimeException and its children) do not. This was meant to be the best of both. It became neither.
public record FetchResult(int status, String body) {}
public class FetchException extends Exception {
public FetchException(String msg, Throwable cause) { super(msg, cause); }
}
public FetchResult fetch(String path) throws FetchException {
String data;
try {
data = Files.readString(Path.of(path));
} catch (IOException e) {
throw new FetchException("read config " + path, e);
}
JsonNode cfg;
try {
cfg = new ObjectMapper().readTree(data);
} catch (JsonProcessingException e) {
throw new FetchException("parse config", e);
}
String endpoint = cfg.path("endpoint").asText(null);
if (endpoint == null) {
throw new FetchException("config missing 'endpoint'", null);
}
HttpRequest req = HttpRequest.newBuilder(URI.create(endpoint)).build();
try {
HttpResponse<String> resp = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
return new FetchResult(resp.statusCode(), resp.body());
} catch (IOException | InterruptedException e) {
throw new FetchException("call " + endpoint, e);
}
}
What Java gets right: checked exceptions force you to decide. You catch, you wrap, or you declare. The type signature carries real information about failure. Wrap with throws FetchException and the caller knows.
What Java gets wrong: two decades of escape hatches. People wrap checked exceptions in RuntimeException to dodge signatures, especially inside lambdas that do not accept throws. Spring's @Transactional swallows and reclassifies. Kotlin dropped checked exceptions entirely. The throws Exception catch-all exists in production Java today and it tells you nothing. The feature that was meant to make failure explicit is the one most actively routed around.
JavaScript / TypeScript: try/catch with an any-shaped error
In TypeScript, the catch binding is typed as unknown (since TS 4.4). Before that it was any. In neither case does the type system know what a function can throw. The signature is a lie.
type FetchResult = { status: number; body: string };
class FetchError extends Error {
constructor(message: string, public cause?: unknown) {
super(message);
this.name = "FetchError";
}
}
export async function fetchIt(path: string): Promise<FetchResult> {
let raw: string;
try {
raw = await fs.readFile(path, "utf8");
} catch (e) {
throw new FetchError(`read config ${path}`, e);
}
let cfg: { endpoint?: string };
try {
cfg = JSON.parse(raw);
} catch (e) {
throw new FetchError("parse config", e);
}
if (!cfg.endpoint) {
throw new FetchError("config missing 'endpoint'");
}
try {
const resp = await fetch(cfg.endpoint);
const body = await resp.text();
return { status: resp.status, body };
} catch (e) {
throw new FetchError(`call ${cfg.endpoint}`, e);
}
}
Some teams wrap this in a Result<T, E> helper (neverthrow, ts-results, effect-ts) to get Rust-style handling back. It works, but it is a library choice the rest of the ecosystem does not honor. Every third-party call still throws, and one forgotten await still leaks a rejected promise.
What JS gets right: nothing about the language-level error model, honestly. The Result libraries are good. Effect is interesting. The platform pushes you toward them.
What JS gets wrong: async functions that reject without an await become unhandled promise rejections that crash the process on Node. throw accepts anything, including a string. instanceof Error is not reliable across realms (iframes, workers). The cause property helps chain errors but has been standard only since ES2022.
What this costs in production traces
Hook each example up to OpenTelemetry. What lands in your trace exporter is very different:
-
Go: each wrapped error is a string. Span status is
Error, span event recordserr.Error(). The wrap chain gives you exact file:line context througherrors.Unwrap. You canerrors.Ison a sentinel and change span attributes accordingly. -
Rust:
thiserrorenums serialize cleanly. The trace carries the variant name as an attribute. A fatalFetchError::HttpvsFetchError::Iois a one-line dashboard split. No runtime type sniffing. -
Python:
logging.exceptioninside anexceptgives you the full traceback. The gap is what happens to theKeyErrornobody expected: it reaches the framework handler, gets logged as a 500, and you never learn which dict key was missing unless your framework captures local variables. -
Java: stack traces are verbose but rich. The problem is the unchecked exception that leaked through a Spring boundary and got wrapped in
UndeclaredThrowableException. Your Sentry groups by the wrapper, not the cause. -
JS:
error.causechaining is the 2022 fix. If your code or your deps predate it, Sentry sees only the top error and you lose the reason. Unhandled rejections still take down Node unless you've registered a global handler.
The pattern: Go and Rust make failure a first-class value, so it is also first-class in your telemetry. Python, Java, and JS all route real failures through mechanisms (unchecked exceptions, framework wrappers, swallowed promise rejections) that silently lose information on the way to your dashboards.
The opinionated verdict
Rust's Result<T, E> is the best type-theoretic answer. Errors are values, the compiler tracks every variant, and ? makes the happy path readable without hiding the failure path. If you are starting a new service today and team skill permits, this is the model that will hurt you least in five years.
Go's explicit err is the most pragmatic answer for medium teams. You do not need to teach anyone monads, lifetimes, or thiserror. A new engineer reads Go code and knows where every failure can happen on day one. The verbosity is the readability.
Python, Java, and JavaScript all share the same production failure mode: the exception that nobody caught. Python hides the surface area in the lack of signatures. Java hides it under unchecked exceptions and framework wrappers. JS hides it behind any-typed catches and unawaited promises. Each of them can be disciplined into safety with custom hierarchies, linters, and Result libraries, but the discipline is fighting the default.
If you write Python or JS, define an exception base class per subsystem, log with cause, and never catch bare Exception/unknown without re-raising. If you write Java, stop wrapping checked exceptions just to silence the compiler; the compiler is right. If you write Go, stop complaining about if err != nil and read the next three lines of your own code.
If you write Rust, you already know.
flowchart LR
TRACE[Same failure, 5 trace outcomes] --> GO[Go span: error attr + status=ERROR]
TRACE --> RS[Rust span: error attr + status=ERROR]
TRACE --> PY[Python span: often missing if uncaught]
TRACE --> JV[Java span: framework-dependent]
TRACE --> JS[JS span: silent if caught without rethrow]
If this was useful
The observability book has a chapter on how error handling choices show up in distributed traces, and Thinking in Go is the place to go if the Go section here was the one that made you curious. Both are linked below.
- Thinking in Go (primary): landing page — the 2-book Go series covering language fundamentals and hexagonal architecture.
- Observability for LLM Applications: Amazon — how error paths, retries, and failure modes actually show up in production traces.
- Hermes IDE: hermes-ide.com — an IDE for developers who ship with Claude Code and other AI coding tools.
- Me: xgabriel.com | GitHub


Top comments (0)