Error Handling in APIs
Exceptions vs Error Codes
Read aloud is unavailable in this browser.
The Fundamental Question of Failure Signaling
Every API operation can fail. Networks drop packets. Resources become unavailable. Inputs violate constraints. Business rules prohibit actions. The question isn't whether your API will encounter errors—it's how you will communicate those errors to callers.
This decision—exceptions versus error codes—is one of the oldest and most consequential debates in software design. It affects code readability, error propagation, performance characteristics, and the cognitive load imposed on API consumers. Getting it wrong doesn't just create bugs; it creates entire categories of unhandled failures lurking in production systems.
By the end of this page, you will understand the fundamental mechanics of both exception-based and return-code-based error handling, their respective philosophies, when each approach shines (and fails), and how to make principled decisions based on context rather than dogma.
Understanding the Two Paradigms
Before analyzing tradeoffs, we need precise definitions. Both approaches have evolved over decades, and understanding their mechanics is essential to evaluating them fairly.
Exception-Based Error Handling:
Exceptions are language-level constructs that allow a function to signal failure by throwing an object (the exception) which propagates up the call stack until caught by an enclosing handler. The normal return path is bypassed entirely.
Return-Code Error Handling:
Return codes embed error information in the function's return value. The caller receives data that may indicate success or failure, and must explicitly inspect the result to detect errors. The control flow remains linear—no stack unwinding occurs.
1234567891011121314151617181920212223
// Exception-based: errors interrupt normal flowpublic User findUserById(String userId) throws UserNotFoundException { User user = userRepository.lookup(userId); if (user == null) { // Throw interrupts execution and propagates up throw new UserNotFoundException("No user with ID: " + userId); } return user; // Only reached if no exception} // Caller codepublic void processOrder(String userId) { try { User user = findUserById(userId); // Happy path continues here createOrderForUser(user); sendConfirmation(user); } catch (UserNotFoundException e) { // Error handling separated from main logic log.error("Order failed: {}", e.getMessage()); notifySupport(e); }}Languages encode strong opinions about error handling. Java makes exceptions first-class. Go rejects exceptions entirely. Rust elevates return codes with type system support. Understanding these choices helps you work with—not against—your language's grain.
The Philosophy Behind Exceptions
Exceptions emerged from a specific philosophical stance: errors are not normal returns. When a function fails, it shouldn't pretend to return normally. The call contract was violated, and the caller should be forced to acknowledge this violation.
This philosophy manifests in several design properties:
- •Separation of Concerns — Normal code handles the happy path. Exception handlers deal with failures. This separation makes both easier to read and reason about.
- •Mandatory Acknowledgment — Uncaught exceptions terminate the program (or thread). You cannot accidentally ignore an error. Forgetting to check a return code is impossible.
- •Structured Propagation — Exceptions automatically propagate through nested call stacks. Intermediate functions don't need to explicitly pass errors upward—they flow naturally.
- •Rich Error Context — Exception objects carry type information, messages, stack traces, and custom data. Debugging is easier because errors are self-describing.
- •Transaction Rollback Semantics — The unwinding mechanism supports RAII patterns and finally blocks, ensuring resources are released even when errors occur.
12345678910111213141516171819202122232425262728293031323334353637383940
// Exception propagation: intermediate layers don't need to handle errorspublic class OrderService { public void placeOrder(OrderRequest request) { // UserService.findUser() might throw UserNotFoundException // PaymentService.charge() might throw PaymentFailedException // InventoryService.reserve() might throw InsufficientStockException // None of these need try-catch here if OrderService // isn't the right place to handle them User user = userService.findUser(request.getUserId()); Payment payment = paymentService.charge(request.getPaymentInfo()); Reservation reservation = inventoryService.reserve(request.getItems()); // Only reached if ALL previous operations succeeded Order order = Order.create(user, payment, reservation); orderRepository.save(order); }} // Exceptions bubble up to wherever handling makes sense@RestControllerpublic class OrderController { @PostMapping("/orders") public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) { try { orderService.placeOrder(request); return ResponseEntity.ok().build(); } catch (UserNotFoundException e) { return ResponseEntity.status(404).body(errorFor(e)); } catch (PaymentFailedException e) { return ResponseEntity.status(402).body(errorFor(e)); } catch (InsufficientStockException e) { return ResponseEntity.status(409).body(errorFor(e)); } // Unknown exceptions propagate to global handler }}The Invisible Try-Block:
A subtle but crucial aspect of exceptions: every function body is implicitly wrapped in an invisible try-block. Any throwable operation can exit the function at any line. This makes code appear simpler but requires assuming any line might not complete.
Consider the implications:
void processData() {
Resource r1 = acquire(); // Line completes
Resource r2 = acquire(); // Throws! r1 is acquired but what happens?
process(r1, r2); // Never reached
release(r1);
release(r2);
}
Without proper resource management (try-finally, try-with-resources, or RAII), exceptions create resource leaks. The solution is well-known—but the problem exists because exceptions allow any line to exit the function.
The Philosophy Behind Return Codes
Return-code advocates argue from a fundamentally different premise: errors are just data. A function that can fail returns a union type—either a success value or an error value. There's nothing magical about failure; it's simply one possible outcome.
This philosophy produces distinctly different code characteristics:
- •Explicit Control Flow — Every potential failure point is visible in the code. You can trace exactly where errors are checked and propagated.
- •Local Reasoning — A function's behavior is fully determined by its signature and body. No hidden control flow jumps exist. What you see is what happens.
- •Caller Authority — The caller decides whether to handle, propagate, or transform errors. There's no 'force' mechanism—all handling is explicit choice.
- •Type System Integration — Modern return codes (Rust's Result, Haskell's Either) leverage the type system to ensure errors are structurally accounted for.
- •Performance Predictability — No hidden stack unwinding, no exception table lookups. Error handling costs what you see it costs.
1234567891011121314151617181920212223242526272829303132
// Return codes: every error point is explicitfunc PlaceOrder(ctx context.Context, request OrderRequest) error { user, err := userService.FindUser(ctx, request.UserID) if err != nil { return fmt.Errorf("finding user: %w", err) } payment, err := paymentService.Charge(ctx, request.PaymentInfo) if err != nil { return fmt.Errorf("charging payment: %w", err) } reservation, err := inventoryService.Reserve(ctx, request.Items) if err != nil { // We might need to refund the payment we charged if refundErr := paymentService.Refund(ctx, payment.ID); refundErr != nil { // Log refund failure but return original error log.Printf("CRITICAL: refund failed after inventory error: %v", refundErr) } return fmt.Errorf("reserving inventory: %w", err) } order := CreateOrder(user, payment, reservation) if err := orderRepo.Save(ctx, order); err != nil { // Compensating transactions are explicitly handled paymentService.Refund(ctx, payment.ID) inventoryService.Release(ctx, reservation.ID) return fmt.Errorf("saving order: %w", err) } return nil}Notice how the return-code examples make compensating transactions visible. When charging a payment before reserving inventory, you must consider what happens if inventory reservation fails. This explicit handling catches design issues that exception-based code might hide until production.
Critical Tradeoffs Analysis
Neither approach is universally superior. Each trades certain benefits for others. Understanding these tradeoffs deeply enables principled decision-making.
| Characteristic | Exceptions | Return Codes |
|---|---|---|
| Error Visibility | Errors are hidden in control flow; any line might throw | Every error point is explicit in code |
| Propagation Effort | Automatic—exceptions bubble up without code | Manual—every layer must check and forward |
| Ignoring Errors | Impossible to ignore (unhandled exception terminates) | Easy to ignore (just don't check the return) |
| Code Volume | Less code for propagation-heavy cases | More code due to explicit checking |
| Control Flow | Non-local jumps through stack unwinding | Strictly local—returns exit only current function |
| Performance | Zero-cost on happy path; costly when thrown | Small constant cost on every fallible call |
| Debugging | Stack traces included automatically | Must compose error context manually |
| Refactoring Risk | Adding throws changes implicit contracts | Adding error returns changes explicit signatures |
The "Ignored Error" Problem:
Return-code critics correctly identify a critical weakness: forgetting to check errors is trivially easy and produces silent failures.
// This compiles and runs—but silently loses the error
user, _ := FindUser(userId) // Error ignored with blank identifier
ProcessUser(user) // Proceeds with nil user
// Even worse: implicit ignore
user, err := FindUser(userId)
if user != nil { // Should check err != nil first!
ProcessUser(user)
}
This is not theoretical. Studies of large codebases find significant percentages of error returns are unchecked. The fix is discipline and tooling (linters that warn on ignored errors), but the failure mode is real.
The "Hidden Throw" Problem:
Exception critics correctly identify the inverse weakness: every function call might exit your function, and you often don't know which ones.
void transfer(Account from, Account to, Money amount) {
from.debit(amount); // Might throw?
to.credit(amount); // Might throw—but from is already debited!
}
Without explicit documentation or checked exceptions, callers cannot know which operations throw. The fix is documentation discipline and careful contract design, but the failure mode is real.
When to Use Exceptions
Exceptions excel in specific contexts. Use them when their strengths align with your needs and their weaknesses are manageable.
- •Deep Call Stacks with Centralized Handling — When errors originate deep in the call hierarchy but handling occurs at a high level (e.g., HTTP request handlers), exceptions eliminate error-threading code through every intermediate layer.
- •Truly Exceptional Conditions — Events that should 'never' happen under normal operation (configuration parse failures, violated invariants, unreachable code paths) benefit from exception semantics—they should loudly interrupt.
- •Constructor Failures — Constructors don't have return values. If object construction can fail, exceptions are the natural mechanism (or alternatively, factory methods returning optional/result types).
- •Framework Integration — When using frameworks that expect exceptions (most Java frameworks, .NET frameworks), working with the framework's grain is almost always better than fighting it.
- •Rich Error Context Required — When errors need stack traces, chained causes, and structured data for debugging, exception objects naturally support this richness.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Deep call stack: exceptions avoid error threadingpublic class DocumentProcessor { public ProcessedDocument process(RawDocument doc) { // Exceptions from ANY of these nested operations // propagate directly to our caller without intermediate handling Document parsed = parser.parse(doc); // Might throw ParseException Document validated = validator.validate(parsed); // Might throw ValidationException Document enriched = enricher.enrich(validated); // Might throw EnrichmentException return transformer.transform(enriched); // Might throw TransformException }} // Constructor failure: no return value existspublic class DatabaseConnection { private final Connection underlyingConnection; public DatabaseConnection(String connectionString) throws ConnectionException { try { this.underlyingConnection = DriverManager.getConnection(connectionString); this.underlyingConnection.setAutoCommit(false); validateConnection(this.underlyingConnection); } catch (SQLException e) { throw new ConnectionException("Failed to establish connection", e); } // If we reach here, object is fully initialized and valid }} // Rich context with exception chainingpublic void processPayment(PaymentRequest request) throws PaymentException { try { gateway.submit(request); } catch (NetworkException e) { throw new PaymentException( "Payment gateway unreachable", e, // Original exception preserved PaymentErrorCode.GATEWAY_UNAVAILABLE, Map.of( "gatewayHost", gateway.getHost(), "timeout", gateway.getTimeout(), "requestId", request.getId() ) ); }}When to Use Return Codes
Return codes excel in their own contexts. Use them when explicit control flow, performance predictability, or local reasoning matters most.
- •Expected Failure Cases — When 'failure' is a normal outcome (user not found, item out of stock, validation rejection), return codes treat it as data rather than exceptional interruption.
- •Performance-Critical Paths — In tight loops or hot paths where exception unwinding costs matter, return codes provide predictable overhead.
- •Systems Programming — Low-level code, FFI boundaries, and system calls naturally use return codes. Crossing language boundaries with exceptions is complex.
- •Partial Success Scenarios — Operations that can partially succeed (batch processing, multi-resource operations) express results better as data than as thrown exceptions.
- •Functional Composition — Return codes (especially Result/Either types) compose with map, flatMap, and other combinators. Exception handling doesn't compose as cleanly.
1234567891011121314151617181920212223
// Expected failure: not finding a user is normal, not exceptionalfunc FindUserByEmail(email string) (*User, error) { user, err := db.QueryOne("SELECT * FROM users WHERE email = ?", email) if err == sql.ErrNoRows { return nil, nil // No user found—return nil, nil (not an error!) } if err != nil { return nil, fmt.Errorf("query failed: %w", err) } return user, nil} // Caller handles "not found" as a normal casefunc LoginOrRegister(email string) (*User, error) { user, err := FindUserByEmail(email) if err != nil { return nil, err // Actual error: propagate } if user == nil { return RegisterNewUser(email) // Normal flow: create new user } return user, nil}A useful rule: if the caller will almost always need to handle this condition explicitly, use return codes. If the condition represents a genuine surprise that most callers will propagate, use exceptions. 'User not found' is expected; 'database connection lost' is exceptional.
Hybrid Approaches and Language-Specific Patterns
Many real-world systems use hybrid approaches, leveraging exceptions for some error categories and return codes for others. Modern languages increasingly provide mechanisms that blend both philosophies.
- •Exceptions for errors, return codes for expected failures — Database errors throw; 'not found' returns null/empty
- •Optional/Maybe for absence — Use Optional<T> when value might not exist, exceptions when it must exist but lookup failed
- •Result types in exception-based languages — Java's vavr library, C#'s language-ext provide Result<T,E> types
- •Domain exceptions only — Throw only domain-specific exceptions; infrastructure errors use return codes or logging
- •Java — Checked exceptions for recoverable errors, unchecked for bugs; increasingly Optional for absence
- •C# — Unchecked exceptions only; TryParse pattern for expected failures; nullable reference types
- •Kotlin — Exceptions exist but return types often preferred; sealed classes for error hierarchies
- •TypeScript — Exceptions available but Result types increasingly popular in type-safe code
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Hybrid: Optional for "maybe", exceptions for errorspublic class UserRepository { // Not found is normal—return Optional public Optional<User> findByEmail(String email) { return Optional.ofNullable(doQuery(email)); } // Must exist—throw if not found public User getById(String id) throws UserNotFoundException { return findById(id) .orElseThrow(() -> new UserNotFoundException(id)); } // Database failure is exceptional—let it throw private User doQuery(String email) { // SQLException is wrapped in DataAccessException by Spring // and thrown as unchecked—infrastructure errors propagate return jdbcTemplate.queryForObject( "SELECT * FROM users WHERE email = ?", userMapper, email ); }} // Service layer uses both patterns appropriatelypublic class AuthService { public AuthResult authenticate(Credentials creds) { Optional<User> maybeUser = userRepo.findByEmail(creds.email()); if (maybeUser.isEmpty()) { return AuthResult.failure("User not found"); // Return code for expected case } User user = maybeUser.get(); if (!passwordHasher.verify(creds.password(), user.getPasswordHash())) { return AuthResult.failure("Invalid password"); // Return code for expected case } // Token generation failure is exceptional—let it throw String token = tokenService.generate(user); return AuthResult.success(token); }}KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.