← Back to DevBytes

Error Handling Patterns in Racket

Error Handling Patterns in Racket

Error handling in Racket goes far beyond simple try/catch blocks. The language provides a rich set of mechanisms for detecting, signaling, and recovering from errors — rooted in its heritage as a Scheme variant and its embrace of language-oriented programming. Understanding these patterns will help you write robust, maintainable Racket code that fails gracefully and provides meaningful diagnostic information.

What Error Handling Means in Racket

In Racket, errors are not merely exceptions — they are first-class events that flow through a handler chain. The runtime system distinguishes between several categories of abnormal situations:

Each category has its own protocol and expected handling behavior. Mastering error handling means knowing which mechanism to use and when.

Why Error Handling Matters in Racket

Racket is often used in contexts where reliability is paramount — from educational environments where clear error messages help learners, to production servers running long-lived processes, to language implementations built with Racket's macro system. Without proper error handling:

Racket's error handling patterns, when used correctly, transform failures from catastrophic events into managed transitions that preserve system integrity and user trust.

Core Error Handling Primitives

Before diving into patterns, let's review the fundamental building blocks:

error — Signaling a Programming Error

The error function raises a fatal error intended for programmer-level mistakes. It accepts a format string and arguments, similar to printf:

(define (safe-divide numerator denominator)
  (if (= denominator 0)
      (error "safe-divide: division by zero, numerator: ~a" numerator)
      (/ numerator denominator)))

(safe-divide 10 0)
; => Error: safe-divide: division by zero, numerator: 10
;    Context: ... stack trace ...

Errors raised with error are instances of exn:fail and typically should not be caught casually — they indicate bugs that need fixing.

raise — General-Purpose Exception Raising

The raise function throws an arbitrary value as an exception. You can raise any Racket value, but structured exception types are preferred:

(define (validate-age age)
  (unless (and (integer? age) (>= age 0) (< age 150))
    (raise (exn:fail:user
            "Invalid age"
            (list (current-continuation-marks)))))
  age)

(validate-age -5)
; => uncaught exception: user-error: Invalid age

with-handlers — Catching Exceptions

The primary exception-catching form is with-handlers. It takes a list of handler predicates and their associated functions, plus a body expression:

(with-handlers
  ([exn:fail:user?
     (lambda (exn)
       (displayln "Please provide a valid input.")
       #f)]
   [exn:fail?
     (lambda (exn)
       (displayln "A system error occurred.")
       (raise exn))])  ; re-raise unhandled errors
  (validate-age -5))
; => Please provide a valid input.
; => #f

Handlers are tried in order. The first matching predicate gets to handle the exception. If no handler matches, the exception propagates upward.

Pattern 1: Guard Clauses with raise-user-error

For functions that interface with end users, validate arguments early and use raise-user-error to signal problems clearly. This pattern prevents invalid states from propagating deeper into your code:

(define (withdraw-from-account account-id amount)
  (unless (positive? amount)
    (raise-user-error "withdraw amount must be positive, got: ~a" amount))
  (unless (account-exists? account-id)
    (raise-user-error "account ~a does not exist" account-id))
  (let ([balance (get-balance account-id)])
    (when (< balance amount)
      (raise-user-error "insufficient funds: have ~a, need ~a"
                        balance amount)))
  ;; safe to proceed
  (update-balance account-id (- (get-balance account-id) amount))
  (log-transaction account-id 'withdrawal amount))

User errors are instances of exn:fail:user and can be caught separately from system errors, allowing UI layers to display friendly messages while letting system errors bubble up to crash handlers.

Pattern 2: The dynamic-wind Cleanup Pattern

When you need to ensure resource cleanup regardless of exceptions, wrap the critical section in dynamic-wind. It guarantees that cleanup code runs even if an exception flies through the region:

(define (with-temporary-file proc)
  (let ([temp-path (make-temporary-file)])
    (dynamic-wind
      (lambda () (void))           ; before — nothing special
      (lambda () (proc temp-path)) ; body
      (lambda ()                   ; after — always runs
        (when (file-exists? temp-path)
          (delete-file temp-path)
          (displayln "Cleaned up temp file."))))))


(with-temporary-file
  (lambda (path)
    (call-with-output-file path
      (lambda (out)
        (fprintf out "Temporary data\n"))
      #:exists 'truncate)
    (if (> (random) 0.3)
        (error "Simulated random failure")
        (displayln "Success!"))))
; On failure path: "Cleaned up temp file." then error
; On success path: "Success!" then "Cleaned up temp file."

The key insight: dynamic-wind's third thunk is guaranteed to execute exactly once, whether the body returns normally, raises an exception, or is aborted by a continuation escape. This is essential for file handles, database connections, and mutex locks.

Pattern 3: Result Type Pattern (No Exceptions)

Sometimes the best error handling is to avoid exceptions entirely. Model success and failure as values using tagged results. This pattern is common in functional programming and works beautifully in Racket:

(struct Ok (value) #:transparent)
(struct Err (reason) #:transparent)

(define (result-bind r f)
  (match r
    [(Ok v) (f v)]
    [(Err reason) (Err reason)]))

(define (safe-parse-int str)
  (with-handlers ([exn:fail?
                    (lambda (_)
                      (Err (format "Cannot parse '~a' as integer" str)))])
    (Ok (string->number str))))

(define (safe-divide-by n d)
  (if (= d 0)
      (Err (format "Division by zero: ~a/~a" n d))
      (Ok (/ n d))))

;; Composing operations without exception machinery
(define (compute-ratio str)
  (result-bind (safe-parse-int str)
    (lambda (n)
      (safe-divide-by 100 n))))

(compute-ratio "25")   ; => (Ok 4)
(compute-ratio "0")    ; => (Err "Division by zero: 100/0")
(compute-ratio "abc")  ; => (Err "Cannot parse 'abc' as integer")

This pattern shines when errors are expected and part of normal control flow. It makes error paths explicit in the type system (via struct tags) and avoids the performance overhead of exception dispatch.

Pattern 4: Continuation-Based Error Escapes

Racket's first-class continuations allow you to implement custom error escape mechanisms without touching the exception system at all. This is useful for creating domain-specific control flow:

(define (with-early-exit body)
  (call/cc
    (lambda (abort)
      (let ([fail (lambda (msg)
                    (abort (list 'failure msg)))])
        (call-with-values
          (lambda () (body fail))
          (lambda results
            (apply abort (cons 'success results))))))))

;; Usage: a parser that can bail out on any malformed token
(define (parse-tokens tokens fail)
  (define (token-value token)
    (match token
      [(? symbol?) token]
      [_ (fail (format "Expected symbol, got: ~a" token))]))
  (map token-value tokens))

(with-early-exit (lambda (fail) (parse-tokens '(a b 42 c) fail)))
; => (failure "Expected symbol, got: 42")

(with-early-exit (lambda (fail) (parse-tokens '(a b c) fail)))
; => (success a b c)

This pattern is powerful but should be used sparingly. Reserve it for cases where you need multiple exit points from deep within nested computations without restructuring the entire call stack.

Pattern 5: Parameterized Error Handlers

Racket's parameter system lets you thread error behavior through code without explicit argument passing. Define a parameter for the error handler and let callers install their preferred behavior:

(define current-error-handler
  (make-parameter
    (lambda (exn)
      (displayln (exn-message exn))
      (exit 1))))

(define (report-error exn)
  ((current-error-handler) exn))

(define (robust-main program)
  (with-handlers ([exn:fail?
                    (lambda (exn)
                      (report-error exn))])
    (program)))

;; User installs a custom handler
(parameterize ([current-error-handler
                (lambda (exn)
                  (eprintf "FATAL: ~a\n" (exn-message exn))
                  (exit 2))])
  (robust-main (lambda () (error "Something went terribly wrong"))))
; => FATAL: Something went terribly wrong (and exits with code 2)

Parameters are thread-safe and can be shadowed for lexical regions. This pattern decouples error reporting from error detection, making your libraries adaptable to different logging and monitoring infrastructures.

Pattern 6: Contracts for Pre- and Post-Conditions

Racket's contract system provides declarative error checking at module boundaries. Contracts catch violations early, with detailed blame information pointing to the violating party:

(module math-ops racket
  (provide
    (contract-out
      [safe-sqrt (-> positive? real?)])))

(define (safe-sqrt x)
  (sqrt x))

;; In another module:
(require 'math-ops)
(safe-sqrt -1)
; => safe-sqrt: contract violation
;    expected: positive?
;    given: -1
;    in: the 1st argument of
;        (-> positive? real?)
;    contract from: math-ops
;    blaming: caller
;    at: ...

Contracts are particularly valuable at API boundaries where you want to enforce invariants without cluttering your implementation with manual checks. The blame system tells you precisely who violated the contract — caller or implementation.

Pattern 7: Thread-Aware Error Propagation

When using Racket's thread and place constructs, errors don't automatically propagate to the parent. You must explicitly communicate failures:

(define (supervised-thread thunk)
  (let ([result-ch (make-channel)]
        [error-ch (make-channel)])
    (thread
      (lambda ()
        (with-handlers ([exn:fail?
                          (lambda (exn)
                            (channel-put error-ch exn))])
          (channel-put result-ch (thunk)))))
    (lambda ()
      (sync/timeout-evt
        1.0  ; wait 1 second
        (handle-evt result-ch
          (lambda (v) v))
        (handle-evt error-ch
          (lambda (exn)
            (displayln "Child thread failed!")
            (raise exn)))))))

(define get-result (supervised-thread
                     (lambda ()
                       (sleep 0.1)
                       (+ 2 2))))
(get-result) ; => 4

(define failing-get (supervised-thread
                      (lambda ()
                        (error "Boom in thread"))))
(failing-get) ; => "Child thread failed!" then raises the error

This pattern is essential for concurrent programs. Always pair threading operations with explicit error channels or use thread/supervise from the racket/supervise library for production-grade supervision trees.

Pattern 8: Logging and Debugging During Errors

When an error occurs, capturing the execution context is invaluable for debugging. Use continuation-mark-set->list to extract contextual information from the marks at the point of failure:

(define current-operation (make-parameter #f))

(define (with-operation-name name thunk)
  (parameterize ([current-operation name])
    (with-continuation-mark 'operation name
      (thunk))))

(define (extract-context exn)
  (let ([marks (exn-continuation-marks exn)])
    (define ops
      (continuation-mark-set->list marks 'operation))
    (displayln "Error context — operations in progress:")
    (for ([op (reverse ops)])
      (displayln (format "  → ~a" op)))))

(with-handlers
  ([exn:fail?
     (lambda (exn)
       (extract-context exn)
       (displayln (exn-message exn)))])
  (with-operation-name "database-migration"
    (with-operation-name "schema-update"
      (with-operation-name "alter-users-table"
        (error "Column 'email' already exists")))))
; => Error context — operations in progress:
;       → database-migration
;       → schema-update
;       → alter-users-table
;    Column 'email' already exists

Combining continuation marks with structured error handlers gives you stack-trace-like context without the overhead and opacity of full stack traces.

Best Practices for Error Handling in Racket

Testing Error Handling

Racket's rackunit library provides specialized assertions for testing error paths. Here's how to write comprehensive error tests:

(require rackunit)

(define (risky-operation x)
  (unless (positive? x)
    (raise-user-error "x must be positive, got ~a" x))
  (/ 100 x))

;; Test that an exception is raised
(test/exn "negative input raises user error"
  exn:fail:user?
  (lambda ()
    (risky-operation -5)))

;; Test the exception message
(let ([exn (test/exn "check message content"
             exn:fail:user?
             (lambda ()
               (risky-operation -5)))])
  (check-string-contains (exn-message exn) "must be positive")
  (check-string-contains (exn-message exn) "-5"))

;; Test success path doesn't raise
(check-equal? (risky-operation 25) 4 "success case returns expected value")

;; Test that cleanup happens
(define cleanup-called? (box #f))
(with-handlers ([exn:fail:user? (lambda (e) (void))])
  (dynamic-wind
    void
    (lambda () (risky-operation -1))
    (lambda ()
      (set-box! cleanup-called? #t))))
(check-true (unbox cleanup-called?) "cleanup runs on exception")

Testing error paths ensures that your error handling works correctly and that future changes don't accidentally break recovery or cleanup logic.

Putting It All Together: A Robust File Processing Pipeline

Here's a complete example that combines multiple patterns — contracts, dynamic-wind for cleanup, result types for recoverable failures, and parameterized error handling:

(require racket/contract)

;; Parameterized error handler
(define current-pipeline-error-handler
  (make-parameter
    (lambda (stage exn)
      (eprintf "Pipeline error in stage ~a: ~a\n"
               stage (exn-message exn)))))

;; Result type for recoverable failures
(struct Success (data) #:transparent)
(struct Failure (stage reason) #:transparent)

;; Contract-protected file reader
(define/contract (read-data-file path)
  (-> path-string? (or/c Success? Failure?))
  (with-handlers ([exn:fail:filesystem?
                    (lambda (e)
                      (Failure 'read (exn-message e)))])
    (define in (open-input-file path))
    (dynamic-wind
      void
      (lambda ()
        (Success (read in)))
      (lambda ()
        (close-input-port in)))))

;; Contract-protected data processing
(define/contract (process-data data)
  (-> list? (or/c Success? Failure?))
  (if (null? data)
      (Failure 'process "empty dataset")
      (with-handlers ([exn:fail?
                        (lambda (e)
                          (Failure 'process (exn-message e)))])
        (Success (map (lambda (x) (* x 2)) data)))))

;; Contract-protected output writer
(define/contract (write-results path results)
  (-> path-string? list? (or/c Success? Failure?))
  (with-handlers ([exn:fail:filesystem?
                    (lambda (e)
                      (Failure 'write (exn-message e)))])
    (define out (open-output-file path #:exists 'truncate))
    (dynamic-wind
      void
      (lambda ()
        (write results out)
        (Success 'written))
      (lambda ()
        (close-output-port out)))))

;; Pipeline orchestrator
(define (run-pipeline input-path output-path)
  (match (read-data-file input-path)
    [(Failure stage reason)
     ((current-pipeline-error-handler) stage (exn:fail:user reason))]
    [(Success data)
     (match (process-data data)
       [(Failure stage reason)
        ((current-pipeline-error-handler) stage (exn:fail:user reason))]
       [(Success processed)
        (match (write-results output-path processed)
          [(Failure stage reason)
           ((current-pipeline-error-handler) stage (exn:fail:user reason))]
          [(Success _)
           (displayln "Pipeline completed successfully")])])]))

;; Execute with custom error handler
(parameterize ([current-pipeline-error-handler
                (lambda (stage exn)
                  (eprintf "[FATAL @~a] ~a\n" stage (exn-message exn))
                  (exit 1))])
  (run-pipeline "input.dat" "output.dat"))

This pipeline demonstrates several principles working together: contracts enforce type correctness at each stage boundary, dynamic-wind guarantees file port closure, result types make failure explicit in the control flow, and the parameterized error handler lets callers customize failure reporting without modifying pipeline code.

Conclusion

Error handling in Racket is a multi-layered discipline that rewards intentional design. The language offers an unusually rich toolkit — from lightweight guard clauses and structured exceptions to contract enforcement, continuation-based escapes, and parameterized error policies. The most effective Racket developers choose patterns based on context: using contracts and error for programmer mistakes, result types for expected failures, dynamic-wind for resource safety, and parameterized handlers for configurable reporting. By combining these patterns thoughtfully, you can build systems where errors are not merely caught but managed — preserving state, guiding users, and providing the diagnostic context needed for rapid resolution. The investment in learning these patterns pays off in codebases that remain clear, resilient, and debuggable as they grow in complexity.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles