Xử lý lỗi trong Go: errors.Is, errors.As và wrap lỗi

1. Tổng quan

Trong Go, lỗi là giá trị bình thường. Hàm trả lỗi về cho nơi gọi, và nơi gọi tự quyết định xử lý. Khi lỗi đi qua nhiều tầng như repository, service, handler, bạn thường muốn thêm ngữ cảnh mà vẫn nhận ra được lỗi gốc.

Bài này hướng dẫn cách wrap lỗi bằng fmt.Errorf với %w, kiểm tra lỗi bằng errors.Is, lấy lỗi tùy biến bằng errors.As và gộp nhiều lỗi bằng errors.Join. Cuối bài có phần test và các lỗi hay gặp. Mọi ví dụ chỉ dùng thư viện chuẩn, chạy được với Go 1.20 trở lên. Riêng errors.AsType cần Go 1.26.

2. Sentinel error và giới hạn của phép so sánh ==

Trong Go, error là một interface rất nhỏ:

type error interface {
	Error() string
}

Cách đơn giản nhất để định nghĩa một lỗi “có tên” là tạo sentinel error. Đó là một biến cấp package mà nơi gọi có thể so sánh trực tiếp:

package main

import (
	"errors"
	"fmt"
)

var ErrOrderNotFound = errors.New("order not found")

func findOrder(id int) error {
	if id != 42 {
		return ErrOrderNotFound
	}
	return nil
}

func main() {
	err := findOrder(7)
	if err == ErrOrderNotFound {
		fmt.Println("Không tìm thấy đơn hàng")
	}
}
Không tìm thấy đơn hàng

errors.New tạo ra một con trỏ mới. Vì vậy err == ErrOrderNotFound chỉ đúng khi err chính là biến đó. Cách này chạy tốt cho tới khi bạn bắt đầu thêm ngữ cảnh vào lỗi.

3. Wrap lỗi với fmt.Errorf và %w

3.1. %w khác %v ở điểm nào

Giả sử tầng service muốn ghi thêm ID đơn hàng vào thông báo lỗi:

func loadOrder(id int) error {
	if err := findOrder(id); err != nil {
		return fmt.Errorf("loadOrder id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := loadOrder(7)
	fmt.Println(err)
	fmt.Println(err == ErrOrderNotFound)
	fmt.Println(errors.Is(err, ErrOrderNotFound))
	fmt.Println(errors.Unwrap(err) == ErrOrderNotFound)
}
loadOrder id=7: order not found
false
true
true

Giải thích từng dòng:

  • fmt.Errorf(... %w ...) tạo một lỗi mới. Lỗi này giữ tham chiếu tới err gốc thông qua method Unwrap() error.
  • err == ErrOrderNotFound trả về false vì err bây giờ là một giá trị khác, gọi là lỗi bọc ngoài.
  • errors.Is duyệt qua chuỗi wrap và tìm thấy ErrOrderNotFound bên trong.
  • errors.Unwrap bóc đúng một lớp và trả về lỗi gốc.

Nếu bạn đổi %w thành %v, thông báo in ra vẫn y hệt. Nhưng errors.Is sẽ trả về false, vì %v chỉ chép chuỗi văn bản chứ không giữ lại lỗi gốc.

3.2. Chuỗi wrap nhiều tầng

Mỗi tầng có thể wrap thêm một lớp. errors.Is vẫn tìm được lỗi gốc dù nó nằm sâu đến đâu:

func handleGetOrder(id int) error {
	if err := loadOrder(id); err != nil {
		return fmt.Errorf("GET /orders/%d: %w", id, err)
	}
	return nil
}

// fmt.Println(handleGetOrder(7))
// fmt.Println(errors.Is(handleGetOrder(7), ErrOrderNotFound))
GET /orders/7: loadOrder id=7: order not found
true

Thông báo lỗi lúc này đọc như một stack trace thu gọn: tầng ngoài cùng nằm bên trái, nguyên nhân gốc nằm bên phải.

4. errors.Is: kiểm tra “lỗi này có phải là X không”

errors.Is(err, target) trả về true nếu bất kỳ lỗi nào trong chuỗi thỏa một trong hai điều kiện:

  1. Bằng target khi so sánh bằng ==, hoặc
  2. Có method Is(error) bool và method đó trả về true với target.

Điều kiện thứ hai cho phép một kiểu lỗi tự khai báo rằng nó “tương đương” với một sentinel. Ví dụ, bạn có lỗi chung ErrNotFound và một kiểu lỗi chi tiết hơn cho khách hàng:

var ErrNotFound = errors.New("not found")

type CustomerNotFoundError struct {
	CustomerID string
}

func (e CustomerNotFoundError) Error() string {
	return "customer " + e.CustomerID + " not found"
}

func (e CustomerNotFoundError) Is(target error) bool {
	return target == ErrNotFound
}

func main() {
	err := fmt.Errorf("checkout: %w", CustomerNotFoundError{CustomerID: "C-001"})
	fmt.Println(errors.Is(err, ErrNotFound))
}
true

Handler HTTP chỉ cần kiểm tra errors.Is(err, ErrNotFound) để trả về 404. Nó không cần biết lỗi đến từ đơn hàng, khách hàng hay sản phẩm.

Lưu ý: Đừng gọi Unwrap bên trong method Is, vì errors.Is đã tự duyệt chuỗi. Method Is chỉ nên trả lời cho chính lớp lỗi hiện tại.

5. errors.As: lấy ra lỗi có kiểu cụ thể

Sentinel chỉ cho bạn biết lỗi là gì. Khi cần thêm dữ liệu như mã lỗi hay ID, bạn dùng một kiểu lỗi tùy biến và lấy nó ra bằng errors.As.

5.1. Kiểu lỗi tùy biến có Unwrap

var ErrInsufficientFunds = errors.New("insufficient funds")

type PaymentError struct {
	OrderID int
	Code    string
	Err     error
}

func (e *PaymentError) Error() string {
	return fmt.Sprintf("payment failed for order %d (%s): %v", e.OrderID, e.Code, e.Err)
}

func (e *PaymentError) Unwrap() error { return e.Err }

type PaymentService struct{ balance int }

func (s *PaymentService) Charge(orderID, amount int) error {
	if amount > s.balance {
		return &PaymentError{OrderID: orderID, Code: "PAY-402", Err: ErrInsufficientFunds}
	}
	s.balance -= amount
	return nil
}

func checkout(s *PaymentService, orderID, amount int) error {
	if err := s.Charge(orderID, amount); err != nil {
		return fmt.Errorf("checkout: %w", err)
	}
	return nil
}

Bên gọi xử lý như sau:

func main() {
	svc := &PaymentService{balance: 100}
	err := checkout(svc, 42, 500)

	var pe *PaymentError
	if errors.As(err, &pe) {
		fmt.Println("code:", pe.Code, "order:", pe.OrderID)
	}
	fmt.Println(errors.Is(err, ErrInsufficientFunds))
	fmt.Println(err)
}
code: PAY-402 order: 42
true
checkout: payment failed for order 42 (PAY-402): insufficient funds

Những điểm cần nắm:

  • errors.As duyệt chuỗi wrap. Khi gặp lỗi đầu tiên gán được vào kiểu *PaymentError, nó gán lỗi đó vào pe rồi trả về true.
  • Vì PaymentError có Unwrap(), errors.Is(err, ErrInsufficientFunds) vẫn đi xuyên được vào trường Err.
  • Tham số thứ hai phải là con trỏ tới biến đích. Ở đây đó là &pe, có kiểu **PaymentError.

5.2. Bẫy pointer receiver

Method Error() được khai báo trên *PaymentError, nên chỉ *PaymentError implement error. Viết như dưới đây sẽ sai:

var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error

go vet bắt được lỗi này lúc build. Hãy chạy go vet ./... trong CI. Quy tắc dễ nhớ: kiểu của biến đích phải giống hệt kiểu bạn đã return. Bạn trả về &PaymentError{...} thì biến đích phải là *PaymentError.

5.3. errors.AsType (Go 1.26+)

Từ Go 1.26, thư viện chuẩn có thêm bản generic gọn hơn. Bạn không cần khai báo biến trước:

if pe, ok := errors.AsType[*PaymentError](err); ok {
	fmt.Println("code:", pe.Code)
}

Cách này an toàn kiểu ngay lúc biên dịch, nên không thể gây ra panic như ở mục 5.2. Nếu dự án đã lên Go 1.26, bạn nên dùng nó.

6. Gộp nhiều lỗi với errors.Join

Từ Go 1.20, một lỗi có thể bọc nhiều lỗi con. Trường hợp điển hình là validate dữ liệu, khi bạn muốn báo tất cả lỗi một lượt thay vì dừng ở lỗi đầu tiên:

var (
	ErrEmptyName    = errors.New("name is empty")
	ErrInvalidEmail = errors.New("email is invalid")
)

type Customer struct {
	Name  string
	Email string
}

func validateCustomer(c Customer) error {
	var errs []error
	if c.Name == "" {
		errs = append(errs, ErrEmptyName)
	}
	if !strings.Contains(c.Email, "@") {
		errs = append(errs, ErrInvalidEmail)
	}
	return errors.Join(errs...)
}

func main() {
	err := validateCustomer(Customer{Name: "", Email: "abc"})
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrEmptyName), errors.Is(err, ErrInvalidEmail))
	fmt.Println(validateCustomer(Customer{Name: "An", Email: "an@shop.vn"}) == nil)
}
name is empty
email is invalid
true true
true

Nhớ thêm "strings" vào phần import. Kết quả cho thấy ba điều:

  • errors.Join nối các thông báo bằng ký tự xuống dòng.
  • errors.Is và errors.As kiểm tra được từng lỗi con.
  • errors.Join trả về nil nếu tất cả tham số đều nil hoặc danh sách rỗng. Vì vậy bạn không cần viết thêm if len(errs) == 0.

fmt.Errorf cũng nhận được nhiều %w trong cùng một chuỗi định dạng, ví dụ fmt.Errorf("sync order: %w; %w", errDB, errCache). Lỗi tạo ra sẽ bọc cả hai lỗi.

7. Viết test cho hành vi lỗi

Khi test, bạn nên kiểm tra danh tính của lỗi bằng errors.Is hoặc errors.As. Đừng so sánh chuỗi thông báo. Dưới đây là một table-driven test cho checkout, đặt trong file checkout_test.go:

package main

import (
	"errors"
	"testing"
)

func TestCheckout(t *testing.T) {
	tests := []struct {
		name     string
		balance  int
		amount   int
		wantErr  error
		wantCode string
	}{
		{name: "đủ tiền", balance: 1000, amount: 500},
		{name: "thiếu tiền", balance: 100, amount: 500,
			wantErr: ErrInsufficientFunds, wantCode: "PAY-402"},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := checkout(&PaymentService{balance: tc.balance}, 42, tc.amount)

			if tc.wantErr == nil {
				if err != nil {
					t.Fatalf("không mong đợi lỗi, nhận được: %v", err)
				}
				return
			}
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantErr)
			}
			var pe *PaymentError
			if !errors.As(err, &pe) {
				t.Fatalf("lỗi không chứa *PaymentError: %v", err)
			}
			if pe.Code != tc.wantCode {
				t.Errorf("code = %q, muốn %q", pe.Code, tc.wantCode)
			}
		})
	}
}

Chạy test:

go test -v -run TestCheckout ./...
=== RUN   TestCheckout
=== RUN   TestCheckout/đủ_tiền
=== RUN   TestCheckout/thiếu_tiền
--- PASS: TestCheckout (0.00s)
    --- PASS: TestCheckout/đủ_tiền (0.00s)
    --- PASS: TestCheckout/thiếu_tiền (0.00s)
PASS

Test này vẫn chạy đúng nếu sau này ai đó sửa thông báo lỗi hoặc thêm một lớp wrap. Đó là lý do nên kiểm tra danh tính thay vì chuỗi.

8. Lỗi hay gặp và khi nào KHÔNG nên wrap

8.1. So sánh chuỗi err.Error()

if strings.Contains(err.Error(), "not found") { ... } // dễ vỡ

Chỉ cần một lần sửa thông báo, hoặc một lỗi khác tình cờ chứa cụm “not found”, là logic hỏng. Hãy dùng sentinel kết hợp errors.Is.

8.2. Typed nil

func (s *PaymentService) Refund(orderID int) error {
	var pe *PaymentError // nil
	// ... không có lỗi ...
	return pe // KHÔNG phải nil!
}

Interface error lúc này chứa kiểu *PaymentError với giá trị nil. Vì interface đã có kiểu, nên err != nil vẫn trả về true. Khi không có lỗi, hãy luôn viết return nil một cách tường minh.

8.3. Wrap làm lộ chi tiết nội bộ

Khi bạn wrap sql.ErrNoRows bằng %w, package của bạn cam kết ngầm rằng nơi gọi có thể kiểm tra errors.Is(err, sql.ErrNoRows). Lỗi đó trở thành một phần API công khai. Sau này bạn đổi sang MongoDB thì code của nơi gọi sẽ hỏng.

Ở ranh giới package, bạn có hai lựa chọn tốt hơn:

if errors.Is(err, sql.ErrNoRows) {
	return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // dịch sang lỗi của domain
}
return fmt.Errorf("find order %d: %v", id, err) // chỉ giữ thông báo, che lỗi gốc

Lưu ý: %w là quyết định về API, không chỉ về định dạng. Chỉ wrap khi bạn thực sự muốn nơi gọi kiểm tra được lỗi gốc.

8.4. Vừa log vừa return

if err != nil {
	log.Printf("charge failed: %v", err)
	return fmt.Errorf("charge: %w", err)
}

Làm vậy ở mọi tầng thì một lỗi sẽ xuất hiện nhiều lần trong log. Quy tắc chung là hoặc xử lý (log), hoặc trả về, đừng làm cả hai. Việc log nên để tầng ngoài cùng đảm nhận, ví dụ handler HTTP hoặc hàm main.

8.5. Ngữ cảnh lặp lại, thiếu thông tin

Những thông báo kiểu failed to load order: failed to query: failed to ... dài mà không nói thêm được gì. Hãy ghi tên thao tác và dữ liệu quan trọng như loadOrder id=7: .... Bỏ chữ “failed” vì đã là lỗi thì hiển nhiên là thất bại.

8.6. Khi nào KHÔNG nên dùng errors.As

Nếu bạn chỉ cần biết lỗi thuộc loại nào, errors.Is với một sentinel là đủ và đơn giản hơn. Chỉ tạo kiểu lỗi tùy biến khi nơi gọi thực sự cần dữ liệu đi kèm, như mã lỗi, trường bị sai hay thời gian retry.

9. Kết luận

Bạn đã đi qua bộ công cụ xử lý lỗi của Go:

  • fmt.Errorf với %w thêm ngữ cảnh mà vẫn giữ lỗi gốc. %v chỉ giữ lại văn bản.
  • errors.Is kiểm tra lỗi có khớp một sentinel không, xuyên suốt chuỗi wrap. Một kiểu lỗi có thể định nghĩa method Is để tự khai báo sự tương đương.
  • errors.As (hoặc errors.AsType từ Go 1.26) lấy ra lỗi có kiểu cụ thể kèm dữ liệu. Kiểu của biến đích phải khớp đúng kiểu đã trả về.
  • errors.Join và nhiều %w cho phép một lỗi bọc nhiều lỗi con.

Cuối cùng, hãy coi việc wrap là một phần thiết kế API. Wrap có chủ đích, dịch lỗi hạ tầng sang lỗi domain ở ranh giới package, và viết test dựa trên danh tính lỗi thay vì chuỗi thông báo.

golangerror handlingerrorsbest practices