Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.13.0] - 2026-04-01

### Changed

- **errors** — `WithFields` and `WithDetails` now copy the caller-supplied map before storing it, preventing external mutation of the error's internal state after the call

### Removed

- **errors** — `CodeTooManyRequests` constant and its `codeStatusMap` entry removed; rate-limit errors are owned by the `middleware` package

### Fixed

- **errors** — Added clarifying doc comment on `(*Error).Is` explaining that matching is code-based and symmetric between two `*Error` values with the same `Code`

## [0.12.3] - 2026-03-31

### Fixed
Expand Down
2 changes: 0 additions & 2 deletions errors/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const (
CodeRequestTooLarge = "REQUEST_TOO_LARGE"
CodeUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
CodeUnprocessable = "UNPROCESSABLE_ENTITY"
CodeTooManyRequests = "TOO_MANY_REQUESTS"
CodeInvalidCredentials = "INVALID_CREDENTIALS"
CodeTokenExpired = "TOKEN_EXPIRED"
CodeTokenInvalid = "TOKEN_INVALID"
Expand Down Expand Up @@ -61,7 +60,6 @@ var codeStatusMap = map[string]int{
CodeRequestTooLarge: http.StatusRequestEntityTooLarge,
CodeUnsupportedMedia: http.StatusUnsupportedMediaType,
CodeUnprocessable: http.StatusUnprocessableEntity,
CodeTooManyRequests: http.StatusTooManyRequests,
CodeInvalidCredentials: http.StatusUnauthorized,
CodeTokenExpired: http.StatusUnauthorized,
CodeTokenInvalid: http.StatusUnauthorized,
Expand Down
16 changes: 14 additions & 2 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ func (e *Error) Unwrap() error {

// Is reports whether target matches this error's Code.
// This allows errors.Is(err, ErrNotFound) to work even when messages differ.
//
// Note: matching is based solely on the Code field, so errors.Is is symmetric
// for two *Error values with the same Code — errors.Is(a, b) and errors.Is(b, a)
// both return true when a.Code == b.Code, regardless of which is the sentinel.
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
Expand Down Expand Up @@ -105,9 +109,13 @@ func (e *Error) WithField(field, message string) *Error {
}

// WithFields sets multiple field errors on a copy of the error.
// The map is copied to prevent external mutation.
func (e *Error) WithFields(fields map[string]string) *Error {
cp := e.clone()
cp.Fields = fields
cp.Fields = make(map[string]string, len(fields))
for k, v := range fields {
cp.Fields[k] = v
}
return cp
}

Expand All @@ -123,9 +131,13 @@ func (e *Error) WithDetail(key string, value any) *Error {
}

// WithDetails sets the details map on a copy of the error.
// The map is copied to prevent external mutation.
func (e *Error) WithDetails(details map[string]any) *Error {
cp := e.clone()
cp.Details = details
cp.Details = make(map[string]any, len(details))
for k, v := range details {
cp.Details[k] = v
}
return cp
}

Expand Down
Loading