Throw away your try/catch
An exploration of error handling strategies
Mihail Mikov
Staff Enginner, SumUp
18.03.2025
Mihail Mikov
Staff Enginner, SumUp
18.03.2025
Hi, I'm Misho!
These days I write mostly golang, documentation and prompts...
I used to have a lot of hobbies, now I have a beautiful 10m old daugther
I use neovim, btw
Error handling is a question of reliability. Let's assume 2 main types of approaches:
This presentation focuses on the latter
4There are many types of safety in computer science.
A try/catch block is the main mechanism for handling exceptions in JavaScript
try {
doSomethingThatMightThrow();
} catch (error) {
console.error("Error caught:", error.message);
// assume we are handling the error properly
} finally {
cleanup();
}
The actual path of execution might be confusing...
function outer() {
try {
middle(); // 1
} catch (e) {
// 4 - Execution jumps here, skipping the rest of middle() and inner()
}
}
function middle() {
inner(); // 2
console.log("PROBLEM: This won't execute if inner() throws"); // never!
}
function inner() {
doSomethingThatMightThrow(); // 3
}
You might never find out an error has actually occrued
function outer() {
try {
inner();
} catch (e) {
// PROBLEM: we'll never hit this block
}
}
function inner() {
try {
doSomethingThatMightThrow()
} catch (e) {
// Don't worry, it's fine..
}
}
11
This coding style is very popular, but is actually problematic
function showDashboard() {
try {
const profile = getData();
const stats = getStats(profile.id);
renderDashboard(profile, stats);
} catch (e) {
// PROBLEM: this block handles too many different errors
}
}
We can handle different errors in specific ways, but that is still doesn't prevent aforementioned issues
function showDashboard() {
try {
const profile = getData();
const stats = getStats(profile.id);
renderDashboard(profile, stats);
} catch (e: DataError) {
// handle data error
} catch (e: StatsError) {
// handle stats error
} catch (e: RenderError) {
// handle render error
} catch (e) {
// handle any unexpected errors
}
}
14
This is most correct, but it looks messy and tedious
let data: Data;
try {
profile = getData();
catch (e) {
data = useDefaultData();
}
let stats: Stats;
try {
stats = getStats(profile.id);
} catch (e) {
stats = useDefaultStats();
}
try {
renderDashboard(profile, stats);
} catch (e) {
// handle rendering error
}
15
try {
doSomething();
} catch (error) {
// QUESTION: What types of errors are we expecting here?
}
Even if we handle each error site individually, neither JS nor TS allows us to know what types of errors a function might generate
16public void readFile(String path) throws IOException, FileNotFoundException {
// Method implementation
}
try {
readFile("config.txt");
} catch (FileNotFoundException e) {
System.err.println("Config file not found: " + e.getMessage());
} catch (IOException e) {
System.err.println("Error reading config: " + e.getMessage());
}
This looks ok, but is actually incomplete - any function in Java can also throw a RuntimeException
Exceptions are sync; rejections - async.
They have a lot of simularities, but also many differences
fetch('https://api.example.com/data')
.then(response => {
if (!response.ok) {
// here we can either throw or reject - what's the difference?
/* (A) */ throw new Error(`HTTP error! Status: ${response.status}`);
/* (B) */ return Promise.reject(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(resp => transformResponse(resp))
.then(data => useData(data))
.catch(error => console.error("PROBLEM: Same issue as with the single catch block"));
19
Additionally there is a another hidden problem - fetch
itself might throw
try {
fetch('https://api.example.com/data')
.then(parseResponse)
.then(validateData)
.then(useData)
.catch(error => log.error("PROBLEM: only ASYNC errors will be caught here"));
} catch (e) {
// PROBLEM: only handle SYNC errors generated from fetch will be caught here
}
Mixing promises with sync code can easily result in uncaught exceptions or unhandled promises
20The order of then
s and catch
es is very important
fetch('https://api.example.com/data')
.then(parseResponse)
.catch(error => {
log.error("Only handles errors from parseResponse")
// if we return, the then chain continues
// if we throw / reject - we jump to the next catch
})
.then(validateData)
.then(useData)
.catch(error => log.error("Handles errors from either validateData or useData"));
A strategic catch
in the middle of a promise can be very useful for fallbacks and recovery
Promise.all([
fetch('/api/users'),
fetch('/api/products'),
]).then(([usersRes, productsRes]) => {
// ALL succeeded
}).catch(error => {
// ANY one failed!!
});
Promise.allSettled([
fetch('/api/users'),
fetch('/api/products'),
]).then(results => {
// results is an array of objects with status 'fulfilled' or 'rejected'
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value);
} else {
console.log('Error:', result.reason);
}
});
});
23
Solves at least one problem - both sync and async errors are handled the same way
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const userData = await response.json();
return userData;
} catch (error) {
console.error('Error fetching user data:', error);
}
}
fetchUserData(123)
.catch(error => console.error("All errors get converted to rejections in async functions"))
24
We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.
-- from the Golang FAQ
26Who remembers good old callback hell?
fs.readFile('file.txt', (err, data) => {
if (err) {
console.error('Error reading file:', err);
return;
}
anotherAsyncFunc(data, (err, result) => {
if (err) {
console.error("Error processing data", err);
return;
}
// and so on
})
});
27
func LoadJsonConfig(path string) (*Config, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
err = json.Unmarshal(dat, &cfg)
if err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}
data, err := LoadJsonConfig("config.json")
if err != nil {
// we still need to explicitly distinguish each failure mode
log.Fatalf("Failed to load config: %v", err)
}
28
Zig implements some nice syntax sugar on top of a model similar to go's
fn readValue() !u32 {
const file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close();
var buffer: [10]u8 = undefined;
const size = try file.read(&buffer);
return std.fmt.parseUnsigned(u32, buffer[0..size], 10);
}
fn main() !void {
const value = readValue() catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
std.debug.print("Value: {}\n", .{value});
}
30
fn read_config_file(path: &str) -> Result<Config, io::Error> {
let file = File::open(path)?; // ? operator propagates errors
let config: Config = serde_json::from_reader(file)?;
Ok(config)
}
fn find_user(id: UserId) -> Option<User> {
if id.is_valid() {
Some(User::new(id))
} else {
None
}
}
match read_config_file("config.json") {
Ok(config) => println!("Config loaded: {}", config.name),
Err(e) => eprintln!("Failed to load config: {}", e),
}
32
(* Using Result type *)
let read_file filename =
try
let channel = open_in filename in
let content = really_input_string channel (in_channel_length channel) in
close_in channel;
Ok content
with
| Sys_error msg -> Error ("System error: " ^ msg)
| _ -> Error "Unknown error while reading file"
(* Pattern matching on result *)
match read_file "data.txt" with
| Ok content -> Printf.printf "File content: %s\n" content
| Error msg -> Printf.eprintf "Error: %s\n" msg
34
Note: This is psudo code!
function getData() {
// NOTE: this is NOT possible in JavaScript
perform log.Info("Logging can be handled as an effect")
// internally get might perform a throw RequestError
const data = perform http.Get("http://some.url/")
return data
}
do {
const data = getData();
} handle http.Get {
// actual implementation of the get effect
resume with data; // Continue execution with the returned value
} handle log.Info {
*// actual implementation of logging
} handle RequestError {
// as with throw catch, the first handle block up the stack "catches" the operation
}
36
Effect Handlers: Operations like perform log.Info()
and perform http.Get()
are effects that are captured and handled by handlers further up the call stack
Resumable Execution: Unlike traditional exceptions, these effects can be handled and then execution can resume from where the effect was performed using resume with
Separation of Concerns: The function getData() doesn't need to know how logging or HTTP requests are implemented - it just declares that it performs these effects
This is also an example of the continuation-passing style
app.get('/api/users/:id', async (req, res, next) => {
try {
const userId = req.params.id;
const user = await database.findUser(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (error) {
// Pass to error-handling middleware
next(error);
}
});
app.use((err, req, res, next) => {
console.error('API Error:', err);
res.status(500).json({ error: 'Internal server error' });
});
39
A lot can go wrong when stringing many services together...
async function getUserProfile(userId) {
try {
// Call user service
const user = await userService.getUser(userId);
// Call billing service
const billing = await billingService.getBillingInfo(userId);
// Call preferences service
const preferences = await preferencesService.getPreferences(userId);
return { user, billing, preferences };
} catch (error) {
if (error.service === 'billing') {
// Handle billing service failure gracefully
return { user, preferences, billing: { status: 'unavailable' } };
}
throw error; // Re-throw other errors
}
}
40
Giving a timeout to a function that calls multiple underlying services, doesn't make it clear whether the timeout applies for all or each.
// Using fetch with a timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
fetch('https://api.example.com/data', {
signal: controller.signal
})
--- vs ---
// Using a deadline approach (NO BUILT-IN SUPPORT, YET)
makeMultipleRequests({ deadline: Date.now() + 5000 })
43
const breaker = new CircuitBreaker({
service: 'payments-api',
failureThreshold: 5,
resetTimeout: 30000
});
async function processPayment(order) {
try {
return await breaker.execute(() => paymentsApi.process(order));
} catch (error) {
if (error.type === 'CIRCUIT_OPEN') {
return await fallbackPaymentProcess(order);
}
throw error;
}
}
class QueryError extends Error {
constructor(message, query) {
super(message);
this.type = 'DatabaseError';
this.query = query;
}
report() {
console.error(`Query Error: ${this.query}`);
monitoring.captureException(this);
this.report() = () => {}; // ensure that the error is reported at most once
}
alert() {
if (this.code >= 500) {
alerting.sendAlert({
level: 'critical',
service: 'database',
details: this
});
}
}
}
45
Moves the knowledge of how an error should be handled inside the error itself.
try {
db.select(`SELECT ...`)
} catch (error) {
if (error instanceof QueryError) {
if (error.isRetryable()) {
// handle retry
}
// the caller doesn't always know whether they should log and/or alert
error.report();
error.alert();
}
}
46
None the strategies we've looked at here take into account purely logical errors!
"1" + 1 == 2 // false
Mihail Mikov
Staff Enginner, SumUp
18.03.2025