net/go.book
All Parts Marketing

Error Handling and Debugging: Concepts and Go Implementation

"Imagine you're a detective in the digital world—errors are your clues, and Go gives you the magnifying glass and notebook to solve every mystery!"


Why Error Handling Matters in Networking

Networking is unpredictable: cables unplug, servers crash, packets vanish. Good error handling is your safety net—it keeps your app from crashing and helps you understand what went wrong.

  • Analogy: Errors are like traffic jams. You can't avoid them, but you can reroute and keep moving!
  • Go's Philosophy: Handle errors early, explicitly, and gracefully. No exceptions, just clear checks.

Why is Error Handling in Go So Unique?

  • No Exceptions: Go does not use exceptions for error handling. Instead, errors are values—just like any other variable.
  • Explicitness: Every function that can fail returns an error as its last return value. You must check it!
  • Simplicity: This makes error handling visible and predictable. No hidden surprises.
  • Idiomatic Go: If you ignore an error, Go will warn you (and experienced Gophers will frown!).
  • Why? The creators of Go wanted to avoid the confusion and unpredictability of exceptions in large, concurrent systems. Explicit error handling makes code easier to read, maintain, and debug.

An error you don't check is a bug you haven't met yet — it is just waiting for the worst possible moment to surface.

Note:

  • The Go team considered exceptions, but after much debate, chose explicit errors for clarity and reliability.
  • "Don't just check errors, handle them gracefully" is a common Go mantra.

Go in Action: Basic Error Handling (Step by Step)

package main

import (
	"errors"
	"fmt"
	"net"
)

func main() {
	// Try to connect to a port with no server.
	conn, err := net.Dial("tcp", "localhost:9999")
	if err != nil {
		// A dial failure can be a timeout, a refused connection,
		// or something else entirely. net.Error tells us which.
		var netErr net.Error
		if errors.As(err, &netErr) && netErr.Timeout() {
			fmt.Println("Timed out connecting:", err) // retry-worthy
		} else {
			fmt.Println("Error connecting:", err) // Print the error
		}
		return
	}
	fmt.Fprintln(conn, "Hello!")
	conn.Close()
}
  • What does each part do?
    • net.Dial: Tries to open a TCP connection.
    • err: If there is an error (e.g., no server), it is captured here.
    • errors.As(err, &netErr): Walks the error chain looking for a value that implements the net.Error interface, even if the error was wrapped along the way.
    • netErr.Timeout(): Tells you whether the failure is a timeout (worth retrying) versus something permanent like "connection refused" (retrying won't help).
    • fmt.Println: Reports the error and exits.

Type assertion vs errors.As
A raw type assertion like err.(net.Error) only works if err is exactly that type. The moment a caller wraps it with fmt.Errorf("...: %w", err), the assertion silently fails and you fall back to generic handling. errors.As unwraps the chain via the error's Unwrap() error method, so it keeps working no matter how many layers of context were added on the way up.

Exercise: Error Handling TCP


Go in Action: Custom Error Messages

package main

import (
	"errors"
	"fmt"
	"net"
)

func main() {
	_, err := net.LookupHost("no-such-hostname.example")
	if err != nil {
		var dnsErr *net.DNSError
		if errors.As(err, &dnsErr) {
			fmt.Printf(
				"DNS error for %q (not found=%v, timeout=%v)\n",
				dnsErr.Name, dnsErr.IsNotFound, dnsErr.IsTimeout,
			)
		} else {
			fmt.Printf("Could not resolve host: %v\n", err)
		}
	} else {
		fmt.Println("Host resolved successfully!")
	}
}
  • What does each part do?
    • net.LookupHost: Tries to resolve a hostname.
    • *net.DNSError: The concrete error type the resolver returns; it carries structured fields (Name, IsNotFound, IsTimeout) instead of just a string.
    • errors.As: Extracts that concrete type out of the generic error interface so you can branch on why the lookup failed, not just that it failed.

Exercise: Error Handling DNS


Go in Action: Wrapping and Propagating Errors

Go 1.13+ introduced error wrapping, so you can add context to errors:

package main
import (
    "fmt"
    "net"
    "errors"
)

func connect(addr string) error {
    conn, err := net.Dial("tcp", addr)
    if err != nil {
        return fmt.Errorf("failed to connect to %s: %w", addr, err)
    }
    defer conn.Close()
    return nil
}

func main() {
    err := connect("localhost:9999")
    if err != nil {
        fmt.Println("Connection error:", err)
        if errors.Is(err, net.ErrClosed) {
            fmt.Println("The connection was closed!")
        }
    }
}
  • What does each part do?
    • fmt.Errorf(...%w...): Wraps the original error with context.
    • errors.Is: Checks for specific error types.

Why wrap instead of just returning err? A bare return err loses where the failure happened. fmt.Errorf("...: %w", err) adds a breadcrumb while keeping the original error reachable via errors.Is/errors.As — each call-stack layer can add its own, so the final message reads like a mini stack trace made of ordinary strings.

Don't compare wrapped errors with ==
err == net.ErrClosed breaks the instant that error is wrapped by fmt.Errorf("...: %w", err) anywhere in the chain, because wrapping produces a brand-new value with a different identity. errors.Is follows the Unwrap() chain and matches the sentinel underneath any number of wrapping layers — always prefer it over ==.

Go in Action: Retrying with Backoff and Wrapped Errors

Real network calls are often transiently broken — retry a bounded number of times with a short pause, rather than giving up immediately. Let's extend connect into a retry loop that keeps every attempt visible through wrapping instead of discarding it:

package main

import (
	"errors"
	"fmt"
	"net"
	"time"
)

func connect(addr string) error {
	conn, err := net.Dial("tcp", addr)
	if err != nil {
		return fmt.Errorf("failed to connect to %s: %w", addr, err)
	}
	defer conn.Close()
	return nil
}

func connectWithRetry(addr string, attempts int) error {
	var lastErr error
	for i := 1; i <= attempts; i++ {
		err := connect(addr)
		if err == nil {
			return nil
		}
		// Wrap again: each retry adds its own layer of context, so
		// the final error tells the whole story of every attempt.
		lastErr = fmt.Errorf("attempt %d/%d: %w", i, attempts, err)

		var netErr net.Error
		if errors.As(err, &netErr) && !netErr.Timeout() {
			// Not a timeout (e.g. "connection refused"): retrying
			// won't fix a server that isn't listening, but a bounded
			// retry still helps if the server is mid-restart.
			_ = netErr
		}
		time.Sleep(time.Duration(i) * 200 * time.Millisecond)
	}
	return lastErr
}

func main() {
	err := connectWithRetry("localhost:9999", 3)
	if err != nil {
		fmt.Println("Connection error:", err)
		if errors.Is(err, net.ErrClosed) {
			fmt.Println("The connection was closed!")
		}
	}
}
  • What does each part do?
    • connectWithRetry: Calls connect up to attempts times, backing off a little longer after each failure (i * 200ms).
    • fmt.Errorf("attempt %d/%d: %w", ...): Wraps the previous wrapped error again, so errors.Is/errors.As still see straight through to the original net.Error at the bottom of the chain.
    • The loop always returns lastErr on total failure — callers get a real, inspectable error, never a silent "it probably worked".

Exercise: Error Handling TCP


Debugging Go Network Applications

  • Print Everything: Use fmt.Println to print variables, errors, and data flows.
  • Log Package: Use log.Printf for more detailed messages with timestamps.
  • panic: Only for truly unexpected, unrecoverable errors (never for normal control flow!).
  • Go Playground: Test and debug code online.
  • Delve: The official Go debugger (dlv debug).
  • net/http/pprof: Built-in profiling for performance bottlenecks.
  • Race Detector: Run go run -race to catch concurrency bugs.

Note:

  • The Go Playground is a real Go program running in a sandboxed environment—useful for quick tests.
  • Go's error values are lightweight enough to return from thousands of goroutines without a second thought.

Go in Action: Logging and Debugging

package main
import (
    "log"
    "net"
)

func main() {
    ln, err := net.Listen("tcp", ":8081")
    if err != nil {
        log.Fatalf("Could not start server: %v", err)
    }
    log.Println("Server listening on :8081")
    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Printf("Error accepting connection: %v", err)
            continue
        }
        log.Printf("Accepted connection from %v", conn.RemoteAddr())
        conn.Close()
    }
}

Exercise: Logging TCP Server

Don't swallow the Close() error
conn.Close() above ignores its return value — fine for a demo, risky in a real server, since a failed Close() on a TCP connection can mean buffered writes never reached the peer. Capture it: if cerr := conn.Close(); cerr != nil { log.Printf("close error: %v", cerr) }. More generally: never let a logged error be treated as a handled one just because you already printed it. Logging is not a substitute for propagating or acting on a failure.

Try It Yourself: Build a Resilient Dialer

Combine this chapter's ideas into one program:

  1. Write dialWithRetry(addr string, attempts int, timeout time.Duration) (net.Conn, error) using net.DialTimeout.
  2. Wrap each failed attempt with fmt.Errorf("attempt %d: %w", i, err) so the final error preserves the whole history.
  3. Use errors.As to detect net.Error; keep retrying only while Timeout() is true, and stop immediately on a permanent error.
  4. Wrap the call in a safeGo-style goroutine and confirm with go run -race that concurrent dialers don't race.
  5. Log every attempt, but still return the final error — logging never replaces propagating it to the caller.

This mirrors what production dialers (database drivers, HTTP clients, message-queue consumers) do every time they connect.


What Happens Under the Hood in Go?

  • Every error in Go is just a value of type error (an interface). You can create your own error types!
  • Go's runtime does not hide errors—if you don't check them, you might miss important clues.
  • Network errors can be temporary (e.g., timeouts) or permanent (e.g., connection refused). Use net.Error to check for timeouts and temporary errors.
  • Go's explicit error handling is designed for reliability in large, concurrent systems—no surprises, no hidden exceptions.
  • Custom error types can implement Unwrap() error so errors.Is/ errors.As see through them, just like fmt.Errorf("...: %w", err) does automatically.
  • Since Go 1.20, errors.Join(err1, err2, ...) combines several errors into one value that errors.Is/errors.As can still inspect individually — handy when closing several connections fails in more than one place at once.

In Go, the error path is not the exceptional path — it is just another return value you are required to look at.


Frequently Asked Questions

Why does err == net.ErrClosed sometimes fail even when the connection really was closed? Because the moment anything in the call chain wraps that error with fmt.Errorf("...: %w", err), the value you're comparing against == is a brand-new wrapper, not the original sentinel. errors.Is exists exactly for this — it walks the Unwrap() chain underneath any number of wrapping layers, so it keeps matching no matter how much context got added on the way up. Reach for errors.Is/errors.As by default and treat raw ==/type-assertion comparisons on errors as a bug waiting to happen.

Should I use a sentinel error or a custom error type for my own package? It depends on what the caller needs to know. A sentinel like io.EOF or net.ErrClosed is enough when there's really only one meaningful kind of failure and callers just need to detect that it happened; a custom struct type, like *net.DNSError in this chapter's DNS example, is worth the extra ceremony when callers also need structured details about why, such as IsTimeout or IsNotFound.

If a goroutine panics, will my deferred recover() in main catch it? No, and this trips people up constantly: recover only has an effect inside a deferred function in the same goroutine that's unwinding. A panic in a goroutine started with go that never recovers takes the whole process down with it, so the defer/recover pair has to live inside that goroutine itself — see the safeGo helper earlier in this chapter for the pattern.

When should I actually use panic instead of just returning an error? Reserve it for programmer errors and broken invariants that should genuinely never happen at runtime — not for ordinary, expected failures like "connection refused" or "host not found." Those are exactly what the error return value is for, and reaching for panic there just throws away the caller's chance to decide what to do about it.

How does retrying with backoff, like connectWithRetry, relate to context and cancellation from the next chapter? This chapter's retry loop uses a fixed attempt count and time.Sleep to back off, which works but can't be interrupted from outside once it starts. The context chapter builds on exactly this pattern by threading a context.Context through the retry loop so a caller can cancel it early or bound the whole operation with a deadline, instead of just hoping the attempt count and sleep durations were chosen well enough.