From 7962ec456789dcfe80a723acd550737152442642 Mon Sep 17 00:00:00 2001 From: kartik Date: Wed, 1 Apr 2026 06:18:01 +0530 Subject: [PATCH] fix(errors): defensive map copies in WithFields/WithDetails; remove CodeTooManyRequests --- CHANGELOG.md | 14 ++++++++++++++ errors/codes.go | 2 -- errors/errors.go | 16 ++++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5236a0..cf979ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/errors/codes.go b/errors/codes.go index b3cbbe1..f9100be 100644 --- a/errors/codes.go +++ b/errors/codes.go @@ -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" @@ -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, diff --git a/errors/errors.go b/errors/errors.go index 80f954e..6b35f74 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -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 { @@ -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 } @@ -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 }