1
0
mirror of https://github.com/redis/go-redis.git synced 2025-12-02 06:22:31 +03:00

Merge branch 'master' into ndyakov/CAE-1625-maint-cluster

This commit is contained in:
Nedyalko Dyakov
2025-11-21 11:25:17 +02:00
committed by GitHub
33 changed files with 2351 additions and 135 deletions

125
.github/RELEASE_NOTES_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,125 @@
# Release Notes Template for go-redis
This template provides a structured format for creating release notes for go-redis releases.
## Format Structure
```markdown
# X.Y.Z (YYYY-MM-DD)
## 🚀 Highlights
### [Category Name]
Brief description of the major feature/change with context and impact.
- Key points
- Performance metrics if applicable
- Links to documentation
### [Another Category]
...
## ✨ New Features
- Feature description ([#XXXX](https://github.com/redis/go-redis/pull/XXXX)) by [@username](https://github.com/username)
- ...
## 🐛 Bug Fixes
- Fix description ([#XXXX](https://github.com/redis/go-redis/pull/XXXX)) by [@username](https://github.com/username)
- ...
## ⚡ Performance
- Performance improvement description ([#XXXX](https://github.com/redis/go-redis/pull/XXXX)) by [@username](https://github.com/username)
- ...
## 🧪 Testing & Infrastructure
- Testing/CI improvement ([#XXXX](https://github.com/redis/go-redis/pull/XXXX)) by [@username](https://github.com/username)
- ...
## 👥 Contributors
We'd like to thank all the contributors who worked on this release!
[@username1](https://github.com/username1), [@username2](https://github.com/username2), ...
---
**Full Changelog**: https://github.com/redis/go-redis/compare/vX.Y-1.Z...vX.Y.Z
```
## Guidelines
### Highlights Section
The Highlights section should contain the **most important** user-facing changes. Common categories include:
- **Typed Errors** - Error handling improvements
- **New Commands** - New Redis commands support (especially for new Redis versions)
- **Search & Vector** - RediSearch and vector-related features
- **Connection Pool** - Pool improvements and performance
- **Metrics & Observability** - Monitoring and instrumentation
- **Breaking Changes** - Any breaking changes (should be prominent)
Each highlight should:
- Have a descriptive title
- Include context about why it matters
- Link to relevant PRs
- Include performance metrics if applicable
### New Features Section
- List all new features with PR links and contributor attribution
- Use descriptive text, not just PR titles
- Group related features together if it makes sense
### Bug Fixes Section
- Only include actual bug fixes
- Be specific about what was broken and how it's fixed
- Include issue links if the PR references an issue
### Performance Section
- Separate from New Features to highlight performance work
- Include metrics when available (e.g., "47-67% faster", "33% less memory")
- Explain the impact on users
### Testing & Infrastructure Section
- Include only important testing/CI changes
- **Exclude** dependency bumps (e.g., dependabot PRs for actions)
- **Exclude** minor CI tweaks unless they're significant
- Include major Redis version updates in CI
### What to Exclude
- Dependency bumps (dependabot PRs)
- Minor documentation typo fixes
- Internal refactoring that doesn't affect users
- Duplicate entries (same PR in multiple sections)
- `dependabot[bot]` from contributors list
### Formatting Rules
1. **PR Links**: Use `([#XXXX](https://github.com/redis/go-redis/pull/XXXX))` format
2. **Contributor Links**: Use `[@username](https://github.com/username)` format
3. **Issue Links**: Use `([#XXXX](https://github.com/redis/go-redis/issues/XXXX))` format
4. **Full Changelog**: Always include at the bottom with correct version comparison
### Getting PR Information
Use GitHub API to fetch PR details:
```bash
# Get recent merged PRs
gh pr list --state merged --limit 50 --json number,title,author,mergedAt,url
```
Or use the GitHub web interface to review merged PRs between releases.
### Example Workflow
1. Gather all merged PRs since last release
2. Categorize PRs by type (feature, bug fix, performance, etc.)
3. Identify the 3-5 most important changes for Highlights
4. Remove duplicates and dependency bumps
5. Add PR and contributor links
6. Review for clarity and completeness
7. Add Full Changelog link with correct version tags
## Example (v9.17.0)
See the v9.17.0 release notes in `RELEASE-NOTES.md` for a complete example following this template.

View File

@@ -24,7 +24,7 @@ runs:
# Mapping of redis version to redis testing containers
declare -A redis_version_mapping=(
["8.4.x"]="8.4-GA-pre.3"
["8.4.x"]="8.4.0"
["8.2.x"]="8.2.1-pre"
["8.0.x"]="8.0.2"
)

View File

@@ -44,7 +44,7 @@ jobs:
# Mapping of redis version to redis testing containers
declare -A redis_version_mapping=(
["8.4.x"]="8.4-GA-pre.3"
["8.4.x"]="8.4.0"
["8.2.x"]="8.2.1-pre"
["8.0.x"]="8.0.2"
)

View File

@@ -16,7 +16,7 @@ jobs:
services:
redis-stack:
image: redislabs/client-libs-test:8.4-GA-pre.3
image: redislabs/client-libs-test:8.4.0
env:
TLS_ENABLED: no
REDIS_CLUSTER: no

View File

@@ -2,7 +2,7 @@ GO_MOD_DIRS := $(shell find . -type f -name 'go.mod' -exec dirname {} \; | sort)
REDIS_VERSION ?= 8.4
RE_CLUSTER ?= false
RCE_DOCKER ?= true
CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.4-GA-pre.3
CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.4.0
docker.start:
export RE_CLUSTER=$(RE_CLUSTER) && \

163
README.md
View File

@@ -2,7 +2,7 @@
[![build workflow](https://github.com/redis/go-redis/actions/workflows/build.yml/badge.svg)](https://github.com/redis/go-redis/actions)
[![PkgGoDev](https://pkg.go.dev/badge/github.com/redis/go-redis/v9)](https://pkg.go.dev/github.com/redis/go-redis/v9?tab=doc)
[![Documentation](https://img.shields.io/badge/redis-documentation-informational)](https://redis.uptrace.dev/)
[![Documentation](https://img.shields.io/badge/redis-documentation-informational)](https://redis.io/docs/latest/develop/clients/go/)
[![Go Report Card](https://goreportcard.com/badge/github.com/redis/go-redis/v9)](https://goreportcard.com/report/github.com/redis/go-redis/v9)
[![codecov](https://codecov.io/github/redis/go-redis/graph/badge.svg?token=tsrCZKuSSw)](https://codecov.io/github/redis/go-redis)
@@ -17,15 +17,15 @@
## Supported versions
In `go-redis` we are aiming to support the last three releases of Redis. Currently, this means we do support:
- [Redis 7.2](https://raw.githubusercontent.com/redis/redis/7.2/00-RELEASENOTES) - using Redis Stack 7.2 for modules support
- [Redis 7.4](https://raw.githubusercontent.com/redis/redis/7.4/00-RELEASENOTES) - using Redis Stack 7.4 for modules support
- [Redis 8.0](https://raw.githubusercontent.com/redis/redis/8.0/00-RELEASENOTES) - using Redis CE 8.0 where modules are included
- [Redis 8.2](https://raw.githubusercontent.com/redis/redis/8.2/00-RELEASENOTES) - using Redis CE 8.2 where modules are included
- [Redis 8.0](https://raw.githubusercontent.com/redis/redis/8.0/00-RELEASENOTES) - using Redis CE 8.0
- [Redis 8.2](https://raw.githubusercontent.com/redis/redis/8.2/00-RELEASENOTES) - using Redis CE 8.2
- [Redis 8.4](https://raw.githubusercontent.com/redis/redis/8.4/00-RELEASENOTES) - using Redis CE 8.4
Although the `go.mod` states it requires at minimum `go 1.18`, our CI is configured to run the tests against all three
versions of Redis and latest two versions of Go ([1.23](https://go.dev/doc/devel/release#go1.23.0),
[1.24](https://go.dev/doc/devel/release#go1.24.0)). We observe that some modules related test may not pass with
Redis Stack 7.2 and some commands are changed with Redis CE 8.0.
Although it is not officially supported, `go-redis/v9` should be able to work with any Redis 7.0+.
Please do refer to the documentation and the tests if you experience any issues. We do plan to update the go version
in the `go.mod` to `go 1.24` in one of the next releases.
@@ -43,10 +43,6 @@ in the `go.mod` to `go 1.24` in one of the next releases.
[Work at Redis](https://redis.com/company/careers/jobs/)
## Documentation
- [English](https://redis.uptrace.dev)
- [简体中文](https://redis.uptrace.dev/zh/)
## Resources
@@ -55,16 +51,18 @@ in the `go.mod` to `go 1.24` in one of the next releases.
- [Reference](https://pkg.go.dev/github.com/redis/go-redis/v9)
- [Examples](https://pkg.go.dev/github.com/redis/go-redis/v9#pkg-examples)
## old documentation
- [English](https://redis.uptrace.dev)
- [简体中文](https://redis.uptrace.dev/zh/)
## Ecosystem
- [Redis Mock](https://github.com/go-redis/redismock)
- [Entra ID (Azure AD)](https://github.com/redis/go-redis-entraid)
- [Distributed Locks](https://github.com/bsm/redislock)
- [Redis Cache](https://github.com/go-redis/cache)
- [Rate limiting](https://github.com/go-redis/redis_rate)
This client also works with [Kvrocks](https://github.com/apache/incubator-kvrocks), a distributed
key value NoSQL database that uses RocksDB as storage engine and is compatible with Redis protocol.
## Features
- Redis commands except QUIT and SYNC.
@@ -75,7 +73,6 @@ key value NoSQL database that uses RocksDB as storage engine and is compatible w
- [Scripting](https://redis.uptrace.dev/guide/lua-scripting.html).
- [Redis Sentinel](https://redis.uptrace.dev/guide/go-redis-sentinel.html).
- [Redis Cluster](https://redis.uptrace.dev/guide/go-redis-cluster.html).
- [Redis Ring](https://redis.uptrace.dev/guide/ring.html).
- [Redis Performance Monitoring](https://redis.uptrace.dev/guide/redis-performance-monitoring.html).
- [Redis Probabilistic [RedisStack]](https://redis.io/docs/data-types/probabilistic/)
- [Customizable read and write buffers size.](#custom-buffer-sizes)
@@ -429,6 +426,144 @@ vals, err := rdb.Eval(ctx, "return {KEYS[1],ARGV[1]}", []string{"key"}, "hello")
res, err := rdb.Do(ctx, "set", "key", "value").Result()
```
## Typed Errors
go-redis provides typed error checking functions for common Redis errors:
```go
// Cluster and replication errors
redis.IsLoadingError(err) // Redis is loading the dataset
redis.IsReadOnlyError(err) // Write to read-only replica
redis.IsClusterDownError(err) // Cluster is down
redis.IsTryAgainError(err) // Command should be retried
redis.IsMasterDownError(err) // Master is down
redis.IsMovedError(err) // Returns (address, true) if key moved
redis.IsAskError(err) // Returns (address, true) if key being migrated
// Connection and resource errors
redis.IsMaxClientsError(err) // Maximum clients reached
redis.IsAuthError(err) // Authentication failed (NOAUTH, WRONGPASS, unauthenticated)
redis.IsPermissionError(err) // Permission denied (NOPERM)
redis.IsOOMError(err) // Out of memory (OOM)
// Transaction errors
redis.IsExecAbortError(err) // Transaction aborted (EXECABORT)
```
### Error Wrapping in Hooks
When wrapping errors in hooks, use custom error types with `Unwrap()` method (preferred) or `fmt.Errorf` with `%w`. Always call `cmd.SetErr()` to preserve error type information:
```go
// Custom error type (preferred)
type AppError struct {
Code string
RequestID string
Err error
}
func (e *AppError) Error() string {
return fmt.Sprintf("[%s] request_id=%s: %v", e.Code, e.RequestID, e.Err)
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Hook implementation
func (h MyHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
err := next(ctx, cmd)
if err != nil {
// Wrap with custom error type
wrappedErr := &AppError{
Code: "REDIS_ERROR",
RequestID: getRequestID(ctx),
Err: err,
}
cmd.SetErr(wrappedErr)
return wrappedErr // Return wrapped error to preserve it
}
return nil
}
}
// Typed error detection works through wrappers
if redis.IsLoadingError(err) {
// Retry logic
}
// Extract custom error if needed
var appErr *AppError
if errors.As(err, &appErr) {
log.Printf("Request: %s", appErr.RequestID)
}
```
Alternatively, use `fmt.Errorf` with `%w`:
```go
wrappedErr := fmt.Errorf("context: %w", err)
cmd.SetErr(wrappedErr)
```
### Pipeline Hook Example
For pipeline operations, use `ProcessPipelineHook`:
```go
type PipelineLoggingHook struct{}
func (h PipelineLoggingHook) DialHook(next redis.DialHook) redis.DialHook {
return next
}
func (h PipelineLoggingHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return next
}
func (h PipelineLoggingHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return func(ctx context.Context, cmds []redis.Cmder) error {
start := time.Now()
// Execute the pipeline
err := next(ctx, cmds)
duration := time.Since(start)
log.Printf("Pipeline executed %d commands in %v", len(cmds), duration)
// Process individual command errors
// Note: Individual command errors are already set on each cmd by the pipeline execution
for _, cmd := range cmds {
if cmdErr := cmd.Err(); cmdErr != nil {
// Check for specific error types using typed error functions
if redis.IsAuthError(cmdErr) {
log.Printf("Auth error in pipeline command %s: %v", cmd.Name(), cmdErr)
} else if redis.IsPermissionError(cmdErr) {
log.Printf("Permission error in pipeline command %s: %v", cmd.Name(), cmdErr)
}
// Optionally wrap individual command errors to add context
// The wrapped error preserves type information through errors.As()
wrappedErr := fmt.Errorf("pipeline cmd %s failed: %w", cmd.Name(), cmdErr)
cmd.SetErr(wrappedErr)
}
}
// Return the pipeline-level error (connection errors, etc.)
// You can wrap it if needed, or return it as-is
return err
}
}
// Register the hook
rdb.AddHook(PipelineLoggingHook{})
// Use pipeline - errors are still properly typed
pipe := rdb.Pipeline()
pipe.Set(ctx, "key1", "value1", 0)
pipe.Get(ctx, "key2")
_, err := pipe.Exec(ctx)
```
## Run the test

View File

@@ -1,5 +1,79 @@
# Release Notes
# 9.17.0 (2025-11-19)
## 🚀 Highlights
### Redis 8.4 Support
Added support for Redis 8.4, including new commands and features ([#3572](https://github.com/redis/go-redis/pull/3572))
### Typed Errors
Introduced typed errors for better error handling using `errors.As` instead of string checks. Errors can now be wrapped and set to commands in hooks without breaking library functionality ([#3602](https://github.com/redis/go-redis/pull/3602))
### New Commands
- **CAS/CAD Commands**: Added support for Compare-And-Set/Compare-And-Delete operations with conditional matching (`IFEQ`, `IFNE`, `IFDEQ`, `IFDNE`) ([#3583](https://github.com/redis/go-redis/pull/3583), [#3595](https://github.com/redis/go-redis/pull/3595))
- **MSETEX**: Atomically set multiple key-value pairs with expiration options and conditional modes ([#3580](https://github.com/redis/go-redis/pull/3580))
- **XReadGroup CLAIM**: Consume both incoming and idle pending entries from streams in a single call ([#3578](https://github.com/redis/go-redis/pull/3578))
- **ACL Commands**: Added `ACLGenPass`, `ACLUsers`, and `ACLWhoAmI` ([#3576](https://github.com/redis/go-redis/pull/3576))
- **SLOWLOG Commands**: Added `SLOWLOG LEN` and `SLOWLOG RESET` ([#3585](https://github.com/redis/go-redis/pull/3585))
- **LATENCY Commands**: Added `LATENCY LATEST` and `LATENCY RESET` ([#3584](https://github.com/redis/go-redis/pull/3584))
### Search & Vector Improvements
- **Hybrid Search**: Added **EXPERIMENTAL** support for the new `FT.HYBRID` command ([#3573](https://github.com/redis/go-redis/pull/3573))
- **Vector Range**: Added `VRANGE` command for vector sets ([#3543](https://github.com/redis/go-redis/pull/3543))
- **FT.INFO Enhancements**: Added vector-specific attributes in FT.INFO response ([#3596](https://github.com/redis/go-redis/pull/3596))
### Connection Pool Improvements
- **Improved Connection Success Rate**: Implemented FIFO queue-based fairness and context pattern for connection creation to prevent premature cancellation under high concurrency ([#3518](https://github.com/redis/go-redis/pull/3518))
- **Connection State Machine**: Resolved race conditions and improved pool performance with proper state tracking ([#3559](https://github.com/redis/go-redis/pull/3559))
- **Pool Performance**: Significant performance improvements with faster semaphores, lockless hook manager, and reduced allocations (47-67% faster Get/Put operations) ([#3565](https://github.com/redis/go-redis/pull/3565))
### Metrics & Observability
- **Canceled Metric Attribute**: Added 'canceled' metrics attribute to distinguish context cancellation errors from other errors ([#3566](https://github.com/redis/go-redis/pull/3566))
## ✨ New Features
- Typed errors with wrapping support ([#3602](https://github.com/redis/go-redis/pull/3602)) by [@ndyakov](https://github.com/ndyakov)
- CAS/CAD commands (marked as experimental) ([#3583](https://github.com/redis/go-redis/pull/3583), [#3595](https://github.com/redis/go-redis/pull/3595)) by [@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis)
- MSETEX command support ([#3580](https://github.com/redis/go-redis/pull/3580)) by [@ofekshenawa](https://github.com/ofekshenawa)
- XReadGroup CLAIM argument ([#3578](https://github.com/redis/go-redis/pull/3578)) by [@ofekshenawa](https://github.com/ofekshenawa)
- ACL commands: GenPass, Users, WhoAmI ([#3576](https://github.com/redis/go-redis/pull/3576)) by [@destinyoooo](https://github.com/destinyoooo)
- SLOWLOG commands: LEN, RESET ([#3585](https://github.com/redis/go-redis/pull/3585)) by [@destinyoooo](https://github.com/destinyoooo)
- LATENCY commands: LATEST, RESET ([#3584](https://github.com/redis/go-redis/pull/3584)) by [@destinyoooo](https://github.com/destinyoooo)
- Hybrid search command (FT.HYBRID) ([#3573](https://github.com/redis/go-redis/pull/3573)) by [@htemelski-redis](https://github.com/htemelski-redis)
- Vector range command (VRANGE) ([#3543](https://github.com/redis/go-redis/pull/3543)) by [@cxljs](https://github.com/cxljs)
- Vector-specific attributes in FT.INFO ([#3596](https://github.com/redis/go-redis/pull/3596)) by [@ndyakov](https://github.com/ndyakov)
- Improved connection pool success rate with FIFO queue ([#3518](https://github.com/redis/go-redis/pull/3518)) by [@cyningsun](https://github.com/cyningsun)
- Canceled metrics attribute for context errors ([#3566](https://github.com/redis/go-redis/pull/3566)) by [@pvragov](https://github.com/pvragov)
## 🐛 Bug Fixes
- Fixed Failover Client MaintNotificationsConfig ([#3600](https://github.com/redis/go-redis/pull/3600)) by [@ajax16384](https://github.com/ajax16384)
- Fixed ACLGenPass function to use the bit parameter ([#3597](https://github.com/redis/go-redis/pull/3597)) by [@destinyoooo](https://github.com/destinyoooo)
- Return error instead of panic from commands ([#3568](https://github.com/redis/go-redis/pull/3568)) by [@dragneelfps](https://github.com/dragneelfps)
- Safety harness in `joinErrors` to prevent panic ([#3577](https://github.com/redis/go-redis/pull/3577)) by [@manisharma](https://github.com/manisharma)
## ⚡ Performance
- Connection state machine with race condition fixes ([#3559](https://github.com/redis/go-redis/pull/3559)) by [@ndyakov](https://github.com/ndyakov)
- Pool performance improvements: 47-67% faster Get/Put, 33% less memory, 50% fewer allocations ([#3565](https://github.com/redis/go-redis/pull/3565)) by [@ndyakov](https://github.com/ndyakov)
## 🧪 Testing & Infrastructure
- Updated to Redis 8.4.0 image ([#3603](https://github.com/redis/go-redis/pull/3603)) by [@ndyakov](https://github.com/ndyakov)
- Added Redis 8.4-RC1-pre to CI ([#3572](https://github.com/redis/go-redis/pull/3572)) by [@ndyakov](https://github.com/ndyakov)
- Refactored tests for idiomatic Go ([#3561](https://github.com/redis/go-redis/pull/3561), [#3562](https://github.com/redis/go-redis/pull/3562), [#3563](https://github.com/redis/go-redis/pull/3563)) by [@12ya](https://github.com/12ya)
## 👥 Contributors
We'd like to thank all the contributors who worked on this release!
[@12ya](https://github.com/12ya), [@ajax16384](https://github.com/ajax16384), [@cxljs](https://github.com/cxljs), [@cyningsun](https://github.com/cyningsun), [@destinyoooo](https://github.com/destinyoooo), [@dragneelfps](https://github.com/dragneelfps), [@htemelski-redis](https://github.com/htemelski-redis), [@manisharma](https://github.com/manisharma), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@pvragov](https://github.com/pvragov)
---
**Full Changelog**: https://github.com/redis/go-redis/compare/v9.16.0...v9.17.0
# 9.16.0 (2025-10-23)
## 🚀 Highlights

View File

@@ -306,7 +306,7 @@ var _ = Describe("ACL permissions", Label("NonRedisEnterprise"), func() {
// no perm for dropindex
err = c.FTDropIndex(ctx, "txt").Err()
Expect(err).ToNot(BeEmpty())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("NOPERM"))
// json set and get have perm
@@ -315,7 +315,7 @@ var _ = Describe("ACL permissions", Label("NonRedisEnterprise"), func() {
// no perm for json clear
err = c.JSONClear(ctx, "foo", "$").Err()
Expect(err).ToNot(BeEmpty())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("NOPERM"))
// perm for reserve
@@ -323,7 +323,7 @@ var _ = Describe("ACL permissions", Label("NonRedisEnterprise"), func() {
// no perm for info
err = c.BFInfo(ctx, "bloom").Err()
Expect(err).ToNot(BeEmpty())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("NOPERM"))
// perm for cf.reserve
@@ -338,7 +338,7 @@ var _ = Describe("ACL permissions", Label("NonRedisEnterprise"), func() {
Expect(c.TSCreate(ctx, "tsts").Err()).NotTo(HaveOccurred())
// noperm for ts.info
err = c.TSInfo(ctx, "tsts").Err()
Expect(err).ToNot(BeEmpty())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("NOPERM"))
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())

View File

@@ -2,7 +2,7 @@
services:
redis:
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4-GA-pre.3}
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0}
platform: linux/amd64
container_name: redis-standalone
environment:
@@ -23,7 +23,7 @@ services:
- all
osscluster:
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4-GA-pre.3}
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0}
platform: linux/amd64
container_name: redis-osscluster
environment:
@@ -40,7 +40,7 @@ services:
- all
sentinel-cluster:
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4-GA-pre.3}
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0}
platform: linux/amd64
container_name: redis-sentinel-cluster
network_mode: "host"
@@ -60,7 +60,7 @@ services:
- all
sentinel:
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4-GA-pre.3}
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0}
platform: linux/amd64
container_name: redis-sentinel
depends_on:
@@ -84,7 +84,7 @@ services:
- all
ring-cluster:
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4-GA-pre.3}
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0}
platform: linux/amd64
container_name: redis-ring-cluster
environment:

258
error.go
View File

@@ -52,34 +52,82 @@ type Error interface {
var _ Error = proto.RedisError("")
func isContextError(err error) bool {
switch err {
case context.Canceled, context.DeadlineExceeded:
return true
default:
return false
// Check for wrapped context errors using errors.Is
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
// isTimeoutError checks if an error is a timeout error, even if wrapped.
// Returns (isTimeout, shouldRetryOnTimeout) where:
// - isTimeout: true if the error is any kind of timeout error
// - shouldRetryOnTimeout: true if Timeout() method returns true
func isTimeoutError(err error) (isTimeout bool, hasTimeoutFlag bool) {
// Check for timeoutError interface (works with wrapped errors)
var te timeoutError
if errors.As(err, &te) {
return true, te.Timeout()
}
// Check for net.Error specifically (common case for network timeouts)
var netErr net.Error
if errors.As(err, &netErr) {
return true, netErr.Timeout()
}
return false, false
}
func shouldRetry(err error, retryTimeout bool) bool {
switch err {
case io.EOF, io.ErrUnexpectedEOF:
return true
case nil, context.Canceled, context.DeadlineExceeded:
if err == nil {
return false
case pool.ErrPoolTimeout:
}
// Check for EOF errors (works with wrapped errors)
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
// Check for context errors (works with wrapped errors)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// Check for pool timeout (works with wrapped errors)
if errors.Is(err, pool.ErrPoolTimeout) {
// connection pool timeout, increase retries. #3289
return true
}
if v, ok := err.(timeoutError); ok {
if v.Timeout() {
// Check for timeout errors (works with wrapped errors)
if isTimeout, hasTimeoutFlag := isTimeoutError(err); isTimeout {
if hasTimeoutFlag {
return retryTimeout
}
return true
}
// Check for typed Redis errors using errors.As (works with wrapped errors)
if proto.IsMaxClientsError(err) {
return true
}
if proto.IsLoadingError(err) {
return true
}
if proto.IsReadOnlyError(err) {
return true
}
if proto.IsMasterDownError(err) {
return true
}
if proto.IsClusterDownError(err) {
return true
}
if proto.IsTryAgainError(err) {
return true
}
// Fallback to string checking for backward compatibility with plain errors
s := err.Error()
if s == "ERR max number of clients reached" {
if strings.HasPrefix(s, "ERR max number of clients reached") {
return true
}
if strings.HasPrefix(s, "LOADING ") {
@@ -88,32 +136,43 @@ func shouldRetry(err error, retryTimeout bool) bool {
if strings.HasPrefix(s, "READONLY ") {
return true
}
if strings.HasPrefix(s, "MASTERDOWN ") {
return true
}
if strings.HasPrefix(s, "CLUSTERDOWN ") {
return true
}
if strings.HasPrefix(s, "TRYAGAIN ") {
return true
}
if strings.HasPrefix(s, "MASTERDOWN ") {
return true
}
return false
}
func isRedisError(err error) bool {
_, ok := err.(proto.RedisError)
return ok
// Check if error implements the Error interface (works with wrapped errors)
var redisErr Error
if errors.As(err, &redisErr) {
return true
}
// Also check for proto.RedisError specifically
var protoRedisErr proto.RedisError
return errors.As(err, &protoRedisErr)
}
func isBadConn(err error, allowTimeout bool, addr string) bool {
switch err {
case nil:
return false
case context.Canceled, context.DeadlineExceeded:
return true
case pool.ErrConnUnusableTimeout:
return true
if err == nil {
return false
}
// Check for context errors (works with wrapped errors)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
// Check for pool timeout errors (works with wrapped errors)
if errors.Is(err, pool.ErrConnUnusableTimeout) {
return true
}
if isRedisError(err) {
@@ -133,7 +192,9 @@ func isBadConn(err error, allowTimeout bool, addr string) bool {
}
if allowTimeout {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Check for network timeout errors (works with wrapped errors)
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return false
}
}
@@ -142,44 +203,143 @@ func isBadConn(err error, allowTimeout bool, addr string) bool {
}
func isMovedError(err error) (moved bool, ask bool, addr string) {
if !isRedisError(err) {
return
// Check for typed MovedError
if movedErr, ok := proto.IsMovedError(err); ok {
addr = movedErr.Addr()
addr = internal.GetAddr(addr)
return true, false, addr
}
// Check for typed AskError
if askErr, ok := proto.IsAskError(err); ok {
addr = askErr.Addr()
addr = internal.GetAddr(addr)
return false, true, addr
}
// Fallback to string checking for backward compatibility
s := err.Error()
switch {
case strings.HasPrefix(s, "MOVED "):
moved = true
case strings.HasPrefix(s, "ASK "):
ask = true
default:
return
if strings.HasPrefix(s, "MOVED ") {
// Parse: MOVED 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
addr = internal.GetAddr(parts[2])
return true, false, addr
}
}
if strings.HasPrefix(s, "ASK ") {
// Parse: ASK 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
addr = internal.GetAddr(parts[2])
return false, true, addr
}
}
ind := strings.LastIndex(s, " ")
if ind == -1 {
return false, false, ""
}
addr = s[ind+1:]
addr = internal.GetAddr(addr)
return
return false, false, ""
}
func isLoadingError(err error) bool {
return strings.HasPrefix(err.Error(), "LOADING ")
return proto.IsLoadingError(err)
}
func isReadOnlyError(err error) bool {
return strings.HasPrefix(err.Error(), "READONLY ")
return proto.IsReadOnlyError(err)
}
func isMovedSameConnAddr(err error, addr string) bool {
redisError := err.Error()
if !strings.HasPrefix(redisError, "MOVED ") {
return false
if movedErr, ok := proto.IsMovedError(err); ok {
return strings.HasSuffix(movedErr.Addr(), addr)
}
return strings.HasSuffix(redisError, " "+addr)
return false
}
//------------------------------------------------------------------------------
// Typed error checking functions for public use.
// These functions work correctly even when errors are wrapped in hooks.
// IsLoadingError checks if an error is a Redis LOADING error, even if wrapped.
// LOADING errors occur when Redis is loading the dataset in memory.
func IsLoadingError(err error) bool {
return proto.IsLoadingError(err)
}
// IsReadOnlyError checks if an error is a Redis READONLY error, even if wrapped.
// READONLY errors occur when trying to write to a read-only replica.
func IsReadOnlyError(err error) bool {
return proto.IsReadOnlyError(err)
}
// IsClusterDownError checks if an error is a Redis CLUSTERDOWN error, even if wrapped.
// CLUSTERDOWN errors occur when the cluster is down.
func IsClusterDownError(err error) bool {
return proto.IsClusterDownError(err)
}
// IsTryAgainError checks if an error is a Redis TRYAGAIN error, even if wrapped.
// TRYAGAIN errors occur when a command cannot be processed and should be retried.
func IsTryAgainError(err error) bool {
return proto.IsTryAgainError(err)
}
// IsMasterDownError checks if an error is a Redis MASTERDOWN error, even if wrapped.
// MASTERDOWN errors occur when the master is down.
func IsMasterDownError(err error) bool {
return proto.IsMasterDownError(err)
}
// IsMaxClientsError checks if an error is a Redis max clients error, even if wrapped.
// This error occurs when the maximum number of clients has been reached.
func IsMaxClientsError(err error) bool {
return proto.IsMaxClientsError(err)
}
// IsMovedError checks if an error is a Redis MOVED error, even if wrapped.
// MOVED errors occur in cluster mode when a key has been moved to a different node.
// Returns the address of the node where the key has been moved and a boolean indicating if it's a MOVED error.
func IsMovedError(err error) (addr string, ok bool) {
if movedErr, isMovedErr := proto.IsMovedError(err); isMovedErr {
return movedErr.Addr(), true
}
return "", false
}
// IsAskError checks if an error is a Redis ASK error, even if wrapped.
// ASK errors occur in cluster mode when a key is being migrated and the client should ask another node.
// Returns the address of the node to ask and a boolean indicating if it's an ASK error.
func IsAskError(err error) (addr string, ok bool) {
if askErr, isAskErr := proto.IsAskError(err); isAskErr {
return askErr.Addr(), true
}
return "", false
}
// IsAuthError checks if an error is a Redis authentication error, even if wrapped.
// Authentication errors occur when:
// - NOAUTH: Redis requires authentication but none was provided
// - WRONGPASS: Redis authentication failed due to incorrect password
// - unauthenticated: Error returned when password changed
func IsAuthError(err error) bool {
return proto.IsAuthError(err)
}
// IsPermissionError checks if an error is a Redis permission error, even if wrapped.
// Permission errors (NOPERM) occur when a user does not have permission to execute a command.
func IsPermissionError(err error) bool {
return proto.IsPermissionError(err)
}
// IsExecAbortError checks if an error is a Redis EXECABORT error, even if wrapped.
// EXECABORT errors occur when a transaction is aborted.
func IsExecAbortError(err error) bool {
return proto.IsExecAbortError(err)
}
// IsOOMError checks if an error is a Redis OOM (Out Of Memory) error, even if wrapped.
// OOM errors occur when Redis is out of memory.
func IsOOMError(err error) bool {
return proto.IsOOMError(err)
}
//------------------------------------------------------------------------------

View File

@@ -2,12 +2,12 @@ package redis_test
import (
"context"
"errors"
"io"
. "github.com/bsm/ginkgo/v2"
. "github.com/bsm/gomega"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/internal/proto"
)
type testTimeout struct {
@@ -39,12 +39,13 @@ var _ = Describe("error", func() {
context.Canceled: false,
context.DeadlineExceeded: false,
redis.ErrPoolTimeout: true,
errors.New("ERR max number of clients reached"): true,
errors.New("LOADING Redis is loading the dataset in memory"): true,
errors.New("READONLY You can't write against a read only replica"): true,
errors.New("CLUSTERDOWN The cluster is down"): true,
errors.New("TRYAGAIN Command cannot be processed, please try again"): true,
errors.New("other"): false,
// Use typed errors instead of plain errors.New()
proto.ParseErrorReply([]byte("-ERR max number of clients reached")): true,
proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory")): true,
proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica")): true,
proto.ParseErrorReply([]byte("-CLUSTERDOWN The cluster is down")): true,
proto.ParseErrorReply([]byte("-TRYAGAIN Command cannot be processed, please try again")): true,
proto.ParseErrorReply([]byte("-ERR other")): false,
}
for err, expected := range data {

728
error_wrapping_test.go Normal file
View File

@@ -0,0 +1,728 @@
package redis_test
import (
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/internal/proto"
)
// TestTypedErrorsWithHookWrapping demonstrates that typed errors work correctly
// even when wrapped by hooks, which is the main improvement of this change.
func TestTypedErrorsWithHookWrapping(t *testing.T) {
tests := []struct {
name string
errorMsg string
checkFunc func(error) bool
testName string
}{
{
name: "LOADING error wrapped in hook",
errorMsg: "LOADING Redis is loading the dataset in memory",
checkFunc: redis.IsLoadingError,
testName: "IsLoadingError",
},
{
name: "READONLY error wrapped in hook",
errorMsg: "READONLY You can't write against a read only replica",
checkFunc: redis.IsReadOnlyError,
testName: "IsReadOnlyError",
},
{
name: "CLUSTERDOWN error wrapped in hook",
errorMsg: "CLUSTERDOWN The cluster is down",
checkFunc: redis.IsClusterDownError,
testName: "IsClusterDownError",
},
{
name: "TRYAGAIN error wrapped in hook",
errorMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
checkFunc: redis.IsTryAgainError,
testName: "IsTryAgainError",
},
{
name: "MASTERDOWN error wrapped in hook",
errorMsg: "MASTERDOWN Link with MASTER is down",
checkFunc: redis.IsMasterDownError,
testName: "IsMasterDownError",
},
{
name: "Max clients error wrapped in hook",
errorMsg: "ERR max number of clients reached",
checkFunc: redis.IsMaxClientsError,
testName: "IsMaxClientsError",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate a Redis error being created
parsedErr := proto.ParseErrorReply([]byte("-" + tt.errorMsg))
// Simulate hook wrapping the error
wrappedErr := fmt.Errorf("hook wrapper: %w", parsedErr)
doubleWrappedErr := fmt.Errorf("another hook: %w", wrappedErr)
// Test that the typed error check works with wrapped errors
if !tt.checkFunc(doubleWrappedErr) {
t.Errorf("%s failed to detect wrapped error: %v", tt.testName, doubleWrappedErr)
}
// Test that the error message is still accessible
if !errors.Is(doubleWrappedErr, parsedErr) {
t.Errorf("errors.Is failed to match wrapped error")
}
// Test that the original error message is preserved in the chain
expectedMsg := tt.errorMsg
if parsedErr.Error() != expectedMsg {
t.Errorf("Error message changed: got %q, want %q", parsedErr.Error(), expectedMsg)
}
// Verify the generic RedisError interface still works
var redisError redis.Error
if !errors.As(doubleWrappedErr, &redisError) {
t.Errorf("Failed to extract redis.Error from wrapped error")
}
})
}
}
// TestMovedAndAskErrorsWithHookWrapping tests MOVED and ASK errors with wrapping
func TestMovedAndAskErrorsWithHookWrapping(t *testing.T) {
tests := []struct {
name string
errorMsg string
expectedAddr string
isMoved bool
}{
{
name: "MOVED error",
errorMsg: "MOVED 3999 127.0.0.1:6381",
expectedAddr: "127.0.0.1:6381",
isMoved: true,
},
{
name: "ASK error",
errorMsg: "ASK 3999 192.168.1.100:6380",
expectedAddr: "192.168.1.100:6380",
isMoved: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create the error
parsedErr := proto.ParseErrorReply([]byte("-" + tt.errorMsg))
// Wrap it in hooks
wrappedErr := fmt.Errorf("hook wrapper: %w", parsedErr)
doubleWrappedErr := fmt.Errorf("another hook: %w", wrappedErr)
// Test address extraction from wrapped error
if tt.isMoved {
addr, ok := redis.IsMovedError(doubleWrappedErr)
if !ok {
t.Errorf("IsMovedError failed to detect wrapped MOVED error")
}
if addr != tt.expectedAddr {
t.Errorf("Address mismatch: got %q, want %q", addr, tt.expectedAddr)
}
} else {
addr, ok := redis.IsAskError(doubleWrappedErr)
if !ok {
t.Errorf("IsAskError failed to detect wrapped ASK error")
}
if addr != tt.expectedAddr {
t.Errorf("Address mismatch: got %q, want %q", addr, tt.expectedAddr)
}
}
})
}
}
// TestBackwardCompatibilityWithStringChecks verifies that old string-based
// error checking still works for backward compatibility
func TestBackwardCompatibilityWithStringChecks(t *testing.T) {
tests := []struct {
name string
errorMsg string
stringPrefix string
}{
{
name: "LOADING error",
errorMsg: "LOADING Redis is loading the dataset in memory",
stringPrefix: "LOADING ",
},
{
name: "READONLY error",
errorMsg: "READONLY You can't write against a read only replica",
stringPrefix: "READONLY ",
},
{
name: "CLUSTERDOWN error",
errorMsg: "CLUSTERDOWN The cluster is down",
stringPrefix: "CLUSTERDOWN ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsedErr := proto.ParseErrorReply([]byte("-" + tt.errorMsg))
// Old-style string checking should still work
errMsg := parsedErr.Error()
if errMsg != tt.errorMsg {
t.Errorf("Error message mismatch: got %q, want %q", errMsg, tt.errorMsg)
}
// String prefix checking should still work
if len(errMsg) < len(tt.stringPrefix) || errMsg[:len(tt.stringPrefix)] != tt.stringPrefix {
t.Errorf("String prefix check failed: error %q doesn't start with %q", errMsg, tt.stringPrefix)
}
})
}
}
// TestErrorWrappingInHookScenario simulates a real-world scenario where
// a hook wraps errors for logging or instrumentation
func TestErrorWrappingInHookScenario(t *testing.T) {
// Simulate a hook that wraps errors for logging
wrapErrorForLogging := func(err error) error {
if err != nil {
return fmt.Errorf("logged error at %s: %w", "2024-01-01T00:00:00Z", err)
}
return nil
}
// Simulate a hook that adds context
addContextToError := func(err error, cmd string) error {
if err != nil {
return fmt.Errorf("command %s failed: %w", cmd, err)
}
return nil
}
// Create a LOADING error
loadingErr := proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory"))
// Wrap it through multiple hooks
err := loadingErr
err = wrapErrorForLogging(err)
err = addContextToError(err, "GET mykey")
// The typed error check should still work
if !redis.IsLoadingError(err) {
t.Errorf("IsLoadingError failed to detect error through multiple hook wrappers")
}
// The error message should contain all the context
errMsg := err.Error()
expectedSubstrings := []string{
"command GET mykey failed",
"logged error at",
"LOADING Redis is loading the dataset in memory",
}
for _, substr := range expectedSubstrings {
if !contains(errMsg, substr) {
t.Errorf("Error message missing expected substring %q: %s", substr, errMsg)
}
}
}
// TestShouldRetryWithTypedErrors tests that shouldRetry works with typed errors
func TestShouldRetryWithTypedErrors(t *testing.T) {
tests := []struct {
name string
errorMsg string
shouldRetry bool
retryTimeout bool
}{
{
name: "LOADING error should retry",
errorMsg: "LOADING Redis is loading the dataset in memory",
shouldRetry: true,
retryTimeout: false,
},
{
name: "READONLY error should retry",
errorMsg: "READONLY You can't write against a read only replica",
shouldRetry: true,
retryTimeout: false,
},
{
name: "CLUSTERDOWN error should retry",
errorMsg: "CLUSTERDOWN The cluster is down",
shouldRetry: true,
retryTimeout: false,
},
{
name: "TRYAGAIN error should retry",
errorMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
shouldRetry: true,
retryTimeout: false,
},
{
name: "MASTERDOWN error should retry",
errorMsg: "MASTERDOWN Link with MASTER is down",
shouldRetry: true,
retryTimeout: false,
},
{
name: "Max clients error should retry",
errorMsg: "ERR max number of clients reached",
shouldRetry: true,
retryTimeout: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := proto.ParseErrorReply([]byte("-" + tt.errorMsg))
// Wrap the error
wrappedErr := fmt.Errorf("hook wrapper: %w", err)
// Test shouldRetry (using the exported ShouldRetry for testing)
result := redis.ShouldRetry(wrappedErr, tt.retryTimeout)
if result != tt.shouldRetry {
t.Errorf("ShouldRetry returned %v, want %v for error: %v", result, tt.shouldRetry, wrappedErr)
}
})
}
}
// TestSetErrWithWrappedError tests that when a hook wraps an error and sets it
// via cmd.SetErr(), the underlying typed error can still be detected
func TestSetErrWithWrappedError(t *testing.T) {
testCtx := context.Background()
// Test with a simulated LOADING error
// We test the mechanism directly without needing a real Redis server
cmd := redis.NewStatusCmd(testCtx, "GET", "key")
loadingErr := proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory"))
wrappedLoadingErr := fmt.Errorf("hook wrapper: %w", loadingErr)
cmd.SetErr(wrappedLoadingErr)
// Verify we can still detect the LOADING error through the wrapper
if !redis.IsLoadingError(cmd.Err()) {
t.Errorf("IsLoadingError failed to detect wrapped error set via SetErr: %v", cmd.Err())
}
// Test with MOVED error
cmd2 := redis.NewStatusCmd(testCtx, "GET", "key")
movedErr := proto.ParseErrorReply([]byte("-MOVED 3999 127.0.0.1:6381"))
wrappedMovedErr := fmt.Errorf("hook wrapper: %w", movedErr)
cmd2.SetErr(wrappedMovedErr)
// Verify we can still detect and extract address from MOVED error
addr, ok := redis.IsMovedError(cmd2.Err())
if !ok {
t.Errorf("IsMovedError failed to detect wrapped error set via SetErr: %v", cmd2.Err())
}
if addr != "127.0.0.1:6381" {
t.Errorf("Address extraction failed: got %q, want %q", addr, "127.0.0.1:6381")
}
// Test with READONLY error
cmd3 := redis.NewStatusCmd(testCtx, "SET", "key", "value")
readonlyErr := proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica"))
wrappedReadonlyErr := fmt.Errorf("custom error wrapper: %w", readonlyErr)
cmd3.SetErr(wrappedReadonlyErr)
// Verify we can still detect the READONLY error through the wrapper
if !redis.IsReadOnlyError(cmd3.Err()) {
t.Errorf("IsReadOnlyError failed to detect wrapped error set via SetErr: %v", cmd3.Err())
}
// Verify the error message contains both the wrapper and original error
errMsg := cmd3.Err().Error()
if !contains(errMsg, "custom error wrapper") {
t.Errorf("Error message missing wrapper context: %v", errMsg)
}
if !contains(errMsg, "READONLY") {
t.Errorf("Error message missing original error: %v", errMsg)
}
}
// AppError is a custom error type for testing
type AppError struct {
Code string
Message string
RequestID string
Err error
}
// Error implements the error interface
func (e *AppError) Error() string {
return fmt.Sprintf("[%s] %s (request_id=%s): %v", e.Code, e.Message, e.RequestID, e.Err)
}
// Unwrap implements the error unwrapping interface - this is critical for errors.As() to work
func (e *AppError) Unwrap() error {
return e.Err
}
// TestCustomErrorTypeWrapping tests that users can wrap Redis errors in their own custom error types
// and still have typed error detection work correctly
func TestCustomErrorTypeWrapping(t *testing.T) {
testCtx := context.Background()
// Test 1: Wrap LOADING error in custom type
cmd1 := redis.NewStatusCmd(testCtx, "GET", "key")
loadingErr := proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory"))
customErr1 := &AppError{
Code: "REDIS_ERROR",
Message: "Database operation failed",
RequestID: "req-12345",
Err: loadingErr,
}
cmd1.SetErr(customErr1)
// Verify typed error detection works through custom error type
if !redis.IsLoadingError(cmd1.Err()) {
t.Errorf("IsLoadingError failed to detect error wrapped in custom type: %v", cmd1.Err())
}
// Verify error message contains custom context
errMsg := cmd1.Err().Error()
if !contains(errMsg, "REDIS_ERROR") || !contains(errMsg, "req-12345") {
t.Errorf("Error message missing custom error context: %v", errMsg)
}
// Test 2: Wrap MOVED error in custom type
cmd2 := redis.NewStatusCmd(testCtx, "GET", "key")
movedErr := proto.ParseErrorReply([]byte("-MOVED 3999 127.0.0.1:6381"))
customErr2 := &AppError{
Code: "CLUSTER_REDIRECT",
Message: "Key moved to different node",
RequestID: "req-67890",
Err: movedErr,
}
cmd2.SetErr(customErr2)
// Verify address extraction works through custom error type
addr, ok := redis.IsMovedError(cmd2.Err())
if !ok {
t.Errorf("IsMovedError failed to detect error wrapped in custom type: %v", cmd2.Err())
}
if addr != "127.0.0.1:6381" {
t.Errorf("Address extraction failed: got %q, want %q", addr, "127.0.0.1:6381")
}
// Test 3: Multiple levels of wrapping (custom type + fmt.Errorf)
cmd3 := redis.NewStatusCmd(testCtx, "SET", "key", "value")
readonlyErr := proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica"))
customErr3 := &AppError{
Code: "WRITE_ERROR",
Message: "Write operation failed",
RequestID: "req-11111",
Err: readonlyErr,
}
// Wrap the custom error again with fmt.Errorf
doubleWrapped := fmt.Errorf("hook context: %w", customErr3)
cmd3.SetErr(doubleWrapped)
// Verify typed error detection works through multiple levels of wrapping
if !redis.IsReadOnlyError(cmd3.Err()) {
t.Errorf("IsReadOnlyError failed to detect error wrapped in custom type + fmt.Errorf: %v", cmd3.Err())
}
// Verify we can unwrap to get the custom error
var appErr *AppError
if !errors.As(cmd3.Err(), &appErr) {
t.Errorf("errors.As failed to extract custom error type from wrapped error")
} else {
if appErr.Code != "WRITE_ERROR" || appErr.RequestID != "req-11111" {
t.Errorf("Custom error fields incorrect: Code=%s, RequestID=%s", appErr.Code, appErr.RequestID)
}
}
}
// TestTimeoutErrorWrapping tests that timeout errors work correctly when wrapped
func TestTimeoutErrorWrapping(t *testing.T) {
// Test 1: Wrapped timeoutError interface
t.Run("Wrapped timeoutError with Timeout()=true", func(t *testing.T) {
timeoutErr := &testTimeoutError{timeout: true, msg: "i/o timeout"}
wrappedErr := fmt.Errorf("hook wrapper: %w", timeoutErr)
doubleWrappedErr := fmt.Errorf("another wrapper: %w", wrappedErr)
// Should NOT retry when retryTimeout=false
if redis.ShouldRetry(doubleWrappedErr, false) {
t.Errorf("Should not retry timeout error when retryTimeout=false")
}
// Should retry when retryTimeout=true
if !redis.ShouldRetry(doubleWrappedErr, true) {
t.Errorf("Should retry timeout error when retryTimeout=true")
}
})
// Test 2: Wrapped timeoutError with Timeout()=false
t.Run("Wrapped timeoutError with Timeout()=false", func(t *testing.T) {
timeoutErr := &testTimeoutError{timeout: false, msg: "connection error"}
wrappedErr := fmt.Errorf("hook wrapper: %w", timeoutErr)
// Should always retry when Timeout()=false
if !redis.ShouldRetry(wrappedErr, false) {
t.Errorf("Should retry non-timeout error even when retryTimeout=false")
}
if !redis.ShouldRetry(wrappedErr, true) {
t.Errorf("Should retry non-timeout error when retryTimeout=true")
}
})
// Test 3: Wrapped net.Error with Timeout()=true
t.Run("Wrapped net.Error", func(t *testing.T) {
netErr := &testNetError{timeout: true, temporary: true, msg: "network timeout"}
wrappedErr := fmt.Errorf("hook context: %w", netErr)
// Should respect retryTimeout parameter
if redis.ShouldRetry(wrappedErr, false) {
t.Errorf("Should not retry network timeout when retryTimeout=false")
}
if !redis.ShouldRetry(wrappedErr, true) {
t.Errorf("Should retry network timeout when retryTimeout=true")
}
})
// Test 4: Multiple levels of wrapping
t.Run("Multiple levels of wrapping", func(t *testing.T) {
timeoutErr := &testTimeoutError{timeout: true, msg: "timeout"}
customErr := &AppError{
Code: "TIMEOUT_ERROR",
Message: "Operation timed out",
RequestID: "req-timeout-123",
Err: timeoutErr,
}
wrappedErr := fmt.Errorf("hook wrapper: %w", customErr)
// Should still detect timeout through multiple wrappers
if redis.ShouldRetry(wrappedErr, false) {
t.Errorf("Should not retry timeout through custom error when retryTimeout=false")
}
if !redis.ShouldRetry(wrappedErr, true) {
t.Errorf("Should retry timeout through custom error when retryTimeout=true")
}
// Should be able to extract custom error
var appErr *AppError
if !errors.As(wrappedErr, &appErr) {
t.Errorf("Should be able to extract AppError from wrapped error")
}
})
}
// testTimeoutError implements the timeoutError interface for testing
type testTimeoutError struct {
timeout bool
msg string
}
func (e *testTimeoutError) Error() string {
return e.msg
}
func (e *testTimeoutError) Timeout() bool {
return e.timeout
}
// testNetError implements net.Error for testing
type testNetError struct {
timeout bool
temporary bool
msg string
}
func (e *testNetError) Error() string {
return e.msg
}
func (e *testNetError) Timeout() bool {
return e.timeout
}
func (e *testNetError) Temporary() bool {
return e.temporary
}
// TestContextErrorWrapping tests that context errors work correctly when wrapped
func TestContextErrorWrapping(t *testing.T) {
t.Run("Wrapped context.Canceled", func(t *testing.T) {
wrappedErr := fmt.Errorf("operation failed: %w", context.Canceled)
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
// Should NOT retry
if redis.ShouldRetry(doubleWrappedErr, false) {
t.Errorf("Should not retry wrapped context.Canceled")
}
if redis.ShouldRetry(doubleWrappedErr, true) {
t.Errorf("Should not retry wrapped context.Canceled even with retryTimeout=true")
}
})
t.Run("Wrapped context.DeadlineExceeded", func(t *testing.T) {
wrappedErr := fmt.Errorf("timeout: %w", context.DeadlineExceeded)
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
// Should NOT retry
if redis.ShouldRetry(doubleWrappedErr, false) {
t.Errorf("Should not retry wrapped context.DeadlineExceeded")
}
if redis.ShouldRetry(doubleWrappedErr, true) {
t.Errorf("Should not retry wrapped context.DeadlineExceeded even with retryTimeout=true")
}
})
}
// TestIOErrorWrapping tests that io errors work correctly when wrapped
func TestIOErrorWrapping(t *testing.T) {
t.Run("Wrapped io.EOF", func(t *testing.T) {
wrappedErr := fmt.Errorf("read failed: %w", io.EOF)
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
// Should retry
if !redis.ShouldRetry(doubleWrappedErr, false) {
t.Errorf("Should retry wrapped io.EOF")
}
})
t.Run("Wrapped io.ErrUnexpectedEOF", func(t *testing.T) {
wrappedErr := fmt.Errorf("read failed: %w", io.ErrUnexpectedEOF)
// Should retry
if !redis.ShouldRetry(wrappedErr, false) {
t.Errorf("Should retry wrapped io.ErrUnexpectedEOF")
}
})
}
// TestPoolErrorWrapping tests that pool errors work correctly when wrapped
func TestPoolErrorWrapping(t *testing.T) {
t.Run("Wrapped pool.ErrPoolTimeout", func(t *testing.T) {
wrappedErr := fmt.Errorf("connection failed: %w", redis.ErrPoolTimeout)
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
// Should retry
if !redis.ShouldRetry(doubleWrappedErr, false) {
t.Errorf("Should retry wrapped pool.ErrPoolTimeout")
}
})
}
// TestRedisErrorWrapping tests that RedisError detection works with wrapped errors
func TestRedisErrorWrapping(t *testing.T) {
t.Run("Wrapped proto.RedisError", func(t *testing.T) {
redisErr := proto.RedisError("ERR something went wrong")
wrappedErr := fmt.Errorf("command failed: %w", redisErr)
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
// Create a command and set the wrapped error
cmd := redis.NewStatusCmd(context.Background(), "GET", "key")
cmd.SetErr(doubleWrappedErr)
// The error should still be recognized as a Redis error
// This is tested indirectly through the typed error system
if !strings.Contains(cmd.Err().Error(), "ERR something went wrong") {
t.Errorf("Error message not preserved through wrapping")
}
})
}
// Helper function to check if a string contains a substring
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
func TestAuthErrorWrapping(t *testing.T) {
t.Run("Wrapped NOAUTH error", func(t *testing.T) {
// Create an auth error
authErr := proto.NewAuthError("NOAUTH Authentication required")
// Wrap it
wrappedErr := fmt.Errorf("hook: %w", authErr)
// Should still be detected
if !redis.IsAuthError(wrappedErr) {
t.Errorf("IsAuthError should detect wrapped NOAUTH error")
}
})
t.Run("Wrapped WRONGPASS error", func(t *testing.T) {
// Create an auth error
authErr := proto.NewAuthError("WRONGPASS invalid username-password pair")
// Wrap it multiple times
wrappedErr := fmt.Errorf("connection error: %w", authErr)
doubleWrappedErr := fmt.Errorf("client error: %w", wrappedErr)
// Should still be detected
if !redis.IsAuthError(doubleWrappedErr) {
t.Errorf("IsAuthError should detect double-wrapped WRONGPASS error")
}
})
t.Run("Wrapped unauthenticated error", func(t *testing.T) {
// Create an auth error
authErr := proto.NewAuthError("ERR unauthenticated")
// Wrap it
wrappedErr := fmt.Errorf("hook: %w", authErr)
// Should still be detected
if !redis.IsAuthError(wrappedErr) {
t.Errorf("IsAuthError should detect wrapped unauthenticated error")
}
})
}
func TestPermissionErrorWrapping(t *testing.T) {
t.Run("Wrapped NOPERM error", func(t *testing.T) {
// Create a permission error
permErr := proto.NewPermissionError("NOPERM this user has no permissions to run the 'flushdb' command")
// Wrap it
wrappedErr := fmt.Errorf("hook: %w", permErr)
// Should still be detected
if !redis.IsPermissionError(wrappedErr) {
t.Errorf("IsPermissionError should detect wrapped NOPERM error")
}
})
}
func TestExecAbortErrorWrapping(t *testing.T) {
t.Run("Wrapped EXECABORT error", func(t *testing.T) {
// Create an EXECABORT error
execAbortErr := proto.NewExecAbortError("EXECABORT Transaction discarded because of previous errors")
// Wrap it
wrappedErr := fmt.Errorf("hook: %w", execAbortErr)
// Should still be detected
if !redis.IsExecAbortError(wrappedErr) {
t.Errorf("IsExecAbortError should detect wrapped EXECABORT error")
}
})
}
func TestOOMErrorWrapping(t *testing.T) {
t.Run("Wrapped OOM error", func(t *testing.T) {
// Create an OOM error
oomErr := proto.NewOOMError("OOM command not allowed when used memory > 'maxmemory'")
// Wrap it
wrappedErr := fmt.Errorf("hook: %w", oomErr)
// Should still be detected
if !redis.IsOOMError(wrappedErr) {
t.Errorf("IsOOMError should detect wrapped OOM error")
}
})
}

View File

@@ -5,7 +5,7 @@ go 1.18
replace github.com/redis/go-redis/v9 => ../..
require (
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/v9 v9.17.0
go.uber.org/zap v1.24.0
)

View File

@@ -5,7 +5,7 @@ go 1.18
replace github.com/redis/go-redis/v9 => ../..
require (
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/v9 v9.17.0
github.com/zeebo/xxh3 v1.0.2
)

View File

@@ -4,7 +4,7 @@ go 1.18
replace github.com/redis/go-redis/v9 => ../..
require github.com/redis/go-redis/v9 v9.16.0
require github.com/redis/go-redis/v9 v9.17.0
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect

View File

@@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../..
require (
github.com/davecgh/go-spew v1.1.1
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/v9 v9.17.0
)
require (

View File

@@ -4,7 +4,7 @@ go 1.18
replace github.com/redis/go-redis/v9 => ../..
require github.com/redis/go-redis/v9 v9.16.0
require github.com/redis/go-redis/v9 v9.17.0
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect

View File

@@ -11,8 +11,8 @@ replace github.com/redis/go-redis/extra/redisotel/v9 => ../../extra/redisotel
replace github.com/redis/go-redis/extra/rediscmd/v9 => ../../extra/rediscmd
require (
github.com/redis/go-redis/extra/redisotel/v9 v9.16.0
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/extra/redisotel/v9 v9.17.0
github.com/redis/go-redis/v9 v9.17.0
github.com/uptrace/uptrace-go v1.21.0
go.opentelemetry.io/otel v1.22.0
)
@@ -25,7 +25,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.17.0 // indirect
go.opentelemetry.io/contrib/instrumentation/runtime v0.46.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect

View File

@@ -4,7 +4,7 @@ go 1.18
replace github.com/redis/go-redis/v9 => ../..
require github.com/redis/go-redis/v9 v9.16.0
require github.com/redis/go-redis/v9 v9.17.0
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect

View File

@@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../..
require (
github.com/davecgh/go-spew v1.1.1
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/v9 v9.17.0
)
require (

BIN
example/throughput/throughput Executable file

Binary file not shown.

View File

@@ -7,8 +7,8 @@ replace github.com/redis/go-redis/v9 => ../..
replace github.com/redis/go-redis/extra/rediscmd/v9 => ../rediscmd
require (
github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/extra/rediscmd/v9 v9.17.0
github.com/redis/go-redis/v9 v9.17.0
go.opencensus.io v0.24.0
)

View File

@@ -7,7 +7,7 @@ replace github.com/redis/go-redis/v9 => ../..
require (
github.com/bsm/ginkgo/v2 v2.12.0
github.com/bsm/gomega v1.27.10
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/v9 v9.17.0
)
require (

View File

@@ -7,8 +7,8 @@ replace github.com/redis/go-redis/v9 => ../..
replace github.com/redis/go-redis/extra/rediscmd/v9 => ../rediscmd
require (
github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/extra/rediscmd/v9 v9.17.0
github.com/redis/go-redis/v9 v9.17.0
go.opentelemetry.io/otel v1.22.0
go.opentelemetry.io/otel/metric v1.22.0
go.opentelemetry.io/otel/sdk v1.22.0

View File

@@ -6,7 +6,7 @@ replace github.com/redis/go-redis/v9 => ../..
require (
github.com/prometheus/client_golang v1.14.0
github.com/redis/go-redis/v9 v9.16.0
github.com/redis/go-redis/v9 v9.17.0
)
require (

View File

@@ -145,6 +145,7 @@ type ConnPool struct {
poolSize atomic.Int32
idleConnsLen atomic.Int32
idleCheckInProgress atomic.Bool
idleCheckNeeded atomic.Bool
stats Stats
waitDurationNs atomic.Int64
@@ -220,44 +221,62 @@ func (p *ConnPool) RemovePoolHook(hook PoolHook) {
}
func (p *ConnPool) checkMinIdleConns() {
// If a check is already in progress, mark that we need another check and return
if !p.idleCheckInProgress.CompareAndSwap(false, true) {
p.idleCheckNeeded.Store(true)
return
}
defer p.idleCheckInProgress.Store(false)
if p.cfg.MinIdleConns == 0 {
p.idleCheckInProgress.Store(false)
return
}
// Only create idle connections if we haven't reached the total pool size limit
// MinIdleConns should be a subset of PoolSize, not additional connections
for p.poolSize.Load() < p.cfg.PoolSize && p.idleConnsLen.Load() < p.cfg.MinIdleConns {
// Try to acquire a semaphore token
if !p.semaphore.TryAcquire() {
// Semaphore is full, can't create more connections
// Keep checking until no more checks are needed
// This handles the case where multiple Remove() calls happen concurrently
for {
// Clear the "check needed" flag before we start
p.idleCheckNeeded.Store(false)
// Only create idle connections if we haven't reached the total pool size limit
// MinIdleConns should be a subset of PoolSize, not additional connections
for p.poolSize.Load() < p.cfg.PoolSize && p.idleConnsLen.Load() < p.cfg.MinIdleConns {
// Try to acquire a semaphore token
if !p.semaphore.TryAcquire() {
// Semaphore is full, can't create more connections
p.idleCheckInProgress.Store(false)
return
}
p.poolSize.Add(1)
p.idleConnsLen.Add(1)
go func() {
defer func() {
if err := recover(); err != nil {
p.poolSize.Add(-1)
p.idleConnsLen.Add(-1)
p.freeTurn()
internal.Logger.Printf(context.Background(), "addIdleConn panic: %+v", err)
}
}()
err := p.addIdleConn()
if err != nil && err != ErrClosed {
p.poolSize.Add(-1)
p.idleConnsLen.Add(-1)
}
p.freeTurn()
}()
}
// If no one requested another check while we were working, we're done
if !p.idleCheckNeeded.Load() {
p.idleCheckInProgress.Store(false)
return
}
p.poolSize.Add(1)
p.idleConnsLen.Add(1)
go func() {
defer func() {
if err := recover(); err != nil {
p.poolSize.Add(-1)
p.idleConnsLen.Add(-1)
p.freeTurn()
internal.Logger.Printf(context.Background(), "addIdleConn panic: %+v", err)
}
}()
err := p.addIdleConn()
if err != nil && err != ErrClosed {
p.poolSize.Add(-1)
p.idleConnsLen.Add(-1)
}
p.freeTurn()
}()
// Otherwise, loop again to handle the new requests
}
}

View File

@@ -50,7 +50,8 @@ func (e RedisError) Error() string { return string(e) }
func (RedisError) RedisError() {}
func ParseErrorReply(line []byte) error {
return RedisError(line[1:])
msg := string(line[1:])
return parseTypedRedisError(msg)
}
//------------------------------------------------------------------------------
@@ -201,7 +202,7 @@ func (r *Reader) ReadLine() ([]byte, error) {
var blobErr string
blobErr, err = r.readStringReply(line)
if err == nil {
err = RedisError(blobErr)
err = parseTypedRedisError(blobErr)
}
return nil, err
case RespAttr:

View File

@@ -0,0 +1,488 @@
package proto
import (
"errors"
"strings"
)
// Typed Redis errors for better error handling with wrapping support.
// These errors maintain backward compatibility by keeping the same error messages.
// LoadingError is returned when Redis is loading the dataset in memory.
type LoadingError struct {
msg string
}
func (e *LoadingError) Error() string {
return e.msg
}
func (e *LoadingError) RedisError() {}
// NewLoadingError creates a new LoadingError with the given message.
func NewLoadingError(msg string) *LoadingError {
return &LoadingError{msg: msg}
}
// ReadOnlyError is returned when trying to write to a read-only replica.
type ReadOnlyError struct {
msg string
}
func (e *ReadOnlyError) Error() string {
return e.msg
}
func (e *ReadOnlyError) RedisError() {}
// NewReadOnlyError creates a new ReadOnlyError with the given message.
func NewReadOnlyError(msg string) *ReadOnlyError {
return &ReadOnlyError{msg: msg}
}
// MovedError is returned when a key has been moved to a different node in a cluster.
type MovedError struct {
msg string
addr string
}
func (e *MovedError) Error() string {
return e.msg
}
func (e *MovedError) RedisError() {}
// Addr returns the address of the node where the key has been moved.
func (e *MovedError) Addr() string {
return e.addr
}
// NewMovedError creates a new MovedError with the given message and address.
func NewMovedError(msg string, addr string) *MovedError {
return &MovedError{msg: msg, addr: addr}
}
// AskError is returned when a key is being migrated and the client should ask another node.
type AskError struct {
msg string
addr string
}
func (e *AskError) Error() string {
return e.msg
}
func (e *AskError) RedisError() {}
// Addr returns the address of the node to ask.
func (e *AskError) Addr() string {
return e.addr
}
// NewAskError creates a new AskError with the given message and address.
func NewAskError(msg string, addr string) *AskError {
return &AskError{msg: msg, addr: addr}
}
// ClusterDownError is returned when the cluster is down.
type ClusterDownError struct {
msg string
}
func (e *ClusterDownError) Error() string {
return e.msg
}
func (e *ClusterDownError) RedisError() {}
// NewClusterDownError creates a new ClusterDownError with the given message.
func NewClusterDownError(msg string) *ClusterDownError {
return &ClusterDownError{msg: msg}
}
// TryAgainError is returned when a command cannot be processed and should be retried.
type TryAgainError struct {
msg string
}
func (e *TryAgainError) Error() string {
return e.msg
}
func (e *TryAgainError) RedisError() {}
// NewTryAgainError creates a new TryAgainError with the given message.
func NewTryAgainError(msg string) *TryAgainError {
return &TryAgainError{msg: msg}
}
// MasterDownError is returned when the master is down.
type MasterDownError struct {
msg string
}
func (e *MasterDownError) Error() string {
return e.msg
}
func (e *MasterDownError) RedisError() {}
// NewMasterDownError creates a new MasterDownError with the given message.
func NewMasterDownError(msg string) *MasterDownError {
return &MasterDownError{msg: msg}
}
// MaxClientsError is returned when the maximum number of clients has been reached.
type MaxClientsError struct {
msg string
}
func (e *MaxClientsError) Error() string {
return e.msg
}
func (e *MaxClientsError) RedisError() {}
// NewMaxClientsError creates a new MaxClientsError with the given message.
func NewMaxClientsError(msg string) *MaxClientsError {
return &MaxClientsError{msg: msg}
}
// AuthError is returned when authentication fails.
type AuthError struct {
msg string
}
func (e *AuthError) Error() string {
return e.msg
}
func (e *AuthError) RedisError() {}
// NewAuthError creates a new AuthError with the given message.
func NewAuthError(msg string) *AuthError {
return &AuthError{msg: msg}
}
// PermissionError is returned when a user lacks required permissions.
type PermissionError struct {
msg string
}
func (e *PermissionError) Error() string {
return e.msg
}
func (e *PermissionError) RedisError() {}
// NewPermissionError creates a new PermissionError with the given message.
func NewPermissionError(msg string) *PermissionError {
return &PermissionError{msg: msg}
}
// ExecAbortError is returned when a transaction is aborted.
type ExecAbortError struct {
msg string
}
func (e *ExecAbortError) Error() string {
return e.msg
}
func (e *ExecAbortError) RedisError() {}
// NewExecAbortError creates a new ExecAbortError with the given message.
func NewExecAbortError(msg string) *ExecAbortError {
return &ExecAbortError{msg: msg}
}
// OOMError is returned when Redis is out of memory.
type OOMError struct {
msg string
}
func (e *OOMError) Error() string {
return e.msg
}
func (e *OOMError) RedisError() {}
// NewOOMError creates a new OOMError with the given message.
func NewOOMError(msg string) *OOMError {
return &OOMError{msg: msg}
}
// parseTypedRedisError parses a Redis error message and returns a typed error if applicable.
// This function maintains backward compatibility by keeping the same error messages.
func parseTypedRedisError(msg string) error {
// Check for specific error patterns and return typed errors
switch {
case strings.HasPrefix(msg, "LOADING "):
return NewLoadingError(msg)
case strings.HasPrefix(msg, "READONLY "):
return NewReadOnlyError(msg)
case strings.HasPrefix(msg, "MOVED "):
// Extract address from "MOVED <slot> <addr>"
addr := extractAddr(msg)
return NewMovedError(msg, addr)
case strings.HasPrefix(msg, "ASK "):
// Extract address from "ASK <slot> <addr>"
addr := extractAddr(msg)
return NewAskError(msg, addr)
case strings.HasPrefix(msg, "CLUSTERDOWN "):
return NewClusterDownError(msg)
case strings.HasPrefix(msg, "TRYAGAIN "):
return NewTryAgainError(msg)
case strings.HasPrefix(msg, "MASTERDOWN "):
return NewMasterDownError(msg)
case msg == "ERR max number of clients reached":
return NewMaxClientsError(msg)
case strings.HasPrefix(msg, "NOAUTH "), strings.HasPrefix(msg, "WRONGPASS "), strings.Contains(msg, "unauthenticated"):
return NewAuthError(msg)
case strings.HasPrefix(msg, "NOPERM "):
return NewPermissionError(msg)
case strings.HasPrefix(msg, "EXECABORT "):
return NewExecAbortError(msg)
case strings.HasPrefix(msg, "OOM "):
return NewOOMError(msg)
default:
// Return generic RedisError for unknown error types
return RedisError(msg)
}
}
// extractAddr extracts the address from MOVED/ASK error messages.
// Format: "MOVED <slot> <addr>" or "ASK <slot> <addr>"
func extractAddr(msg string) string {
ind := strings.LastIndex(msg, " ")
if ind == -1 {
return ""
}
return msg[ind+1:]
}
// IsLoadingError checks if an error is a LoadingError, even if wrapped.
func IsLoadingError(err error) bool {
if err == nil {
return false
}
var loadingErr *LoadingError
if errors.As(err, &loadingErr) {
return true
}
// Check if wrapped error is a RedisError with LOADING prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "LOADING ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "LOADING ")
}
// IsReadOnlyError checks if an error is a ReadOnlyError, even if wrapped.
func IsReadOnlyError(err error) bool {
if err == nil {
return false
}
var readOnlyErr *ReadOnlyError
if errors.As(err, &readOnlyErr) {
return true
}
// Check if wrapped error is a RedisError with READONLY prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "READONLY ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "READONLY ")
}
// IsMovedError checks if an error is a MovedError, even if wrapped.
// Returns the error and a boolean indicating if it's a MovedError.
func IsMovedError(err error) (*MovedError, bool) {
if err == nil {
return nil, false
}
var movedErr *MovedError
if errors.As(err, &movedErr) {
return movedErr, true
}
// Fallback to string checking for backward compatibility
s := err.Error()
if strings.HasPrefix(s, "MOVED ") {
// Parse: MOVED 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
return &MovedError{msg: s, addr: parts[2]}, true
}
}
return nil, false
}
// IsAskError checks if an error is an AskError, even if wrapped.
// Returns the error and a boolean indicating if it's an AskError.
func IsAskError(err error) (*AskError, bool) {
if err == nil {
return nil, false
}
var askErr *AskError
if errors.As(err, &askErr) {
return askErr, true
}
// Fallback to string checking for backward compatibility
s := err.Error()
if strings.HasPrefix(s, "ASK ") {
// Parse: ASK 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
return &AskError{msg: s, addr: parts[2]}, true
}
}
return nil, false
}
// IsClusterDownError checks if an error is a ClusterDownError, even if wrapped.
func IsClusterDownError(err error) bool {
if err == nil {
return false
}
var clusterDownErr *ClusterDownError
if errors.As(err, &clusterDownErr) {
return true
}
// Check if wrapped error is a RedisError with CLUSTERDOWN prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "CLUSTERDOWN ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "CLUSTERDOWN ")
}
// IsTryAgainError checks if an error is a TryAgainError, even if wrapped.
func IsTryAgainError(err error) bool {
if err == nil {
return false
}
var tryAgainErr *TryAgainError
if errors.As(err, &tryAgainErr) {
return true
}
// Check if wrapped error is a RedisError with TRYAGAIN prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "TRYAGAIN ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "TRYAGAIN ")
}
// IsMasterDownError checks if an error is a MasterDownError, even if wrapped.
func IsMasterDownError(err error) bool {
if err == nil {
return false
}
var masterDownErr *MasterDownError
if errors.As(err, &masterDownErr) {
return true
}
// Check if wrapped error is a RedisError with MASTERDOWN prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "MASTERDOWN ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "MASTERDOWN ")
}
// IsMaxClientsError checks if an error is a MaxClientsError, even if wrapped.
func IsMaxClientsError(err error) bool {
if err == nil {
return false
}
var maxClientsErr *MaxClientsError
if errors.As(err, &maxClientsErr) {
return true
}
// Check if wrapped error is a RedisError with max clients prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "ERR max number of clients reached") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "ERR max number of clients reached")
}
// IsAuthError checks if an error is an AuthError, even if wrapped.
func IsAuthError(err error) bool {
if err == nil {
return false
}
var authErr *AuthError
if errors.As(err, &authErr) {
return true
}
// Check if wrapped error is a RedisError with auth error prefix
var redisErr RedisError
if errors.As(err, &redisErr) {
s := redisErr.Error()
return strings.HasPrefix(s, "NOAUTH ") || strings.HasPrefix(s, "WRONGPASS ") || strings.Contains(s, "unauthenticated")
}
// Fallback to string checking for backward compatibility
s := err.Error()
return strings.HasPrefix(s, "NOAUTH ") || strings.HasPrefix(s, "WRONGPASS ") || strings.Contains(s, "unauthenticated")
}
// IsPermissionError checks if an error is a PermissionError, even if wrapped.
func IsPermissionError(err error) bool {
if err == nil {
return false
}
var permErr *PermissionError
if errors.As(err, &permErr) {
return true
}
// Check if wrapped error is a RedisError with NOPERM prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "NOPERM ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "NOPERM ")
}
// IsExecAbortError checks if an error is an ExecAbortError, even if wrapped.
func IsExecAbortError(err error) bool {
if err == nil {
return false
}
var execAbortErr *ExecAbortError
if errors.As(err, &execAbortErr) {
return true
}
// Check if wrapped error is a RedisError with EXECABORT prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "EXECABORT ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "EXECABORT ")
}
// IsOOMError checks if an error is an OOMError, even if wrapped.
func IsOOMError(err error) bool {
if err == nil {
return false
}
var oomErr *OOMError
if errors.As(err, &oomErr) {
return true
}
// Check if wrapped error is a RedisError with OOM prefix
var redisErr RedisError
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "OOM ") {
return true
}
// Fallback to string checking for backward compatibility
return strings.HasPrefix(err.Error(), "OOM ")
}

View File

@@ -0,0 +1,392 @@
package proto
import (
"errors"
"fmt"
"testing"
)
// TestTypedRedisErrors tests that typed Redis errors are created correctly
func TestTypedRedisErrors(t *testing.T) {
tests := []struct {
name string
errorMsg string
expectedType interface{}
expectedMsg string
checkFunc func(error) bool
extractAddr func(error) string
}{
{
name: "LOADING error",
errorMsg: "LOADING Redis is loading the dataset in memory",
expectedType: &LoadingError{},
expectedMsg: "LOADING Redis is loading the dataset in memory",
checkFunc: IsLoadingError,
},
{
name: "READONLY error",
errorMsg: "READONLY You can't write against a read only replica",
expectedType: &ReadOnlyError{},
expectedMsg: "READONLY You can't write against a read only replica",
checkFunc: IsReadOnlyError,
},
{
name: "MOVED error",
errorMsg: "MOVED 3999 127.0.0.1:6381",
expectedType: &MovedError{},
expectedMsg: "MOVED 3999 127.0.0.1:6381",
checkFunc: func(err error) bool {
_, ok := IsMovedError(err)
return ok
},
extractAddr: func(err error) string {
if movedErr, ok := IsMovedError(err); ok {
return movedErr.Addr()
}
return ""
},
},
{
name: "ASK error",
errorMsg: "ASK 3999 127.0.0.1:6381",
expectedType: &AskError{},
expectedMsg: "ASK 3999 127.0.0.1:6381",
checkFunc: func(err error) bool {
_, ok := IsAskError(err)
return ok
},
extractAddr: func(err error) string {
if askErr, ok := IsAskError(err); ok {
return askErr.Addr()
}
return ""
},
},
{
name: "CLUSTERDOWN error",
errorMsg: "CLUSTERDOWN The cluster is down",
expectedType: &ClusterDownError{},
expectedMsg: "CLUSTERDOWN The cluster is down",
checkFunc: IsClusterDownError,
},
{
name: "TRYAGAIN error",
errorMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
expectedType: &TryAgainError{},
expectedMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
checkFunc: IsTryAgainError,
},
{
name: "MASTERDOWN error",
errorMsg: "MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'",
expectedType: &MasterDownError{},
expectedMsg: "MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'",
checkFunc: IsMasterDownError,
},
{
name: "Max clients error",
errorMsg: "ERR max number of clients reached",
expectedType: &MaxClientsError{},
expectedMsg: "ERR max number of clients reached",
checkFunc: IsMaxClientsError,
},
{
name: "NOAUTH error",
errorMsg: "NOAUTH Authentication required",
expectedType: &AuthError{},
expectedMsg: "NOAUTH Authentication required",
checkFunc: IsAuthError,
},
{
name: "WRONGPASS error",
errorMsg: "WRONGPASS invalid username-password pair",
expectedType: &AuthError{},
expectedMsg: "WRONGPASS invalid username-password pair",
checkFunc: IsAuthError,
},
{
name: "unauthenticated error",
errorMsg: "ERR unauthenticated",
expectedType: &AuthError{},
expectedMsg: "ERR unauthenticated",
checkFunc: IsAuthError,
},
{
name: "NOPERM error",
errorMsg: "NOPERM this user has no permissions to run the 'flushdb' command",
expectedType: &PermissionError{},
expectedMsg: "NOPERM this user has no permissions to run the 'flushdb' command",
checkFunc: IsPermissionError,
},
{
name: "EXECABORT error",
errorMsg: "EXECABORT Transaction discarded because of previous errors",
expectedType: &ExecAbortError{},
expectedMsg: "EXECABORT Transaction discarded because of previous errors",
checkFunc: IsExecAbortError,
},
{
name: "OOM error",
errorMsg: "OOM command not allowed when used memory > 'maxmemory'",
expectedType: &OOMError{},
expectedMsg: "OOM command not allowed when used memory > 'maxmemory'",
checkFunc: IsOOMError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parseTypedRedisError(tt.errorMsg)
// Check error message is preserved
if err.Error() != tt.expectedMsg {
t.Errorf("Error message mismatch: got %q, want %q", err.Error(), tt.expectedMsg)
}
// Check error type using errors.As
if !errors.As(err, &tt.expectedType) {
t.Errorf("Error type mismatch: expected %T, got %T", tt.expectedType, err)
}
// Check using the helper function
if tt.checkFunc != nil && !tt.checkFunc(err) {
t.Errorf("Helper function returned false for error: %v", err)
}
// Check address extraction for MOVED/ASK errors
if tt.extractAddr != nil {
addr := tt.extractAddr(err)
if addr == "" {
t.Errorf("Failed to extract address from error: %v", err)
}
}
})
}
}
// TestWrappedTypedErrors tests that typed errors work correctly when wrapped
func TestWrappedTypedErrors(t *testing.T) {
tests := []struct {
name string
errorMsg string
checkFunc func(error) bool
}{
{
name: "Wrapped LOADING error",
errorMsg: "LOADING Redis is loading the dataset in memory",
checkFunc: IsLoadingError,
},
{
name: "Wrapped READONLY error",
errorMsg: "READONLY You can't write against a read only replica",
checkFunc: IsReadOnlyError,
},
{
name: "Wrapped CLUSTERDOWN error",
errorMsg: "CLUSTERDOWN The cluster is down",
checkFunc: IsClusterDownError,
},
{
name: "Wrapped TRYAGAIN error",
errorMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
checkFunc: IsTryAgainError,
},
{
name: "Wrapped MASTERDOWN error",
errorMsg: "MASTERDOWN Link with MASTER is down",
checkFunc: IsMasterDownError,
},
{
name: "Wrapped Max clients error",
errorMsg: "ERR max number of clients reached",
checkFunc: IsMaxClientsError,
},
{
name: "Wrapped NOAUTH error",
errorMsg: "NOAUTH Authentication required",
checkFunc: IsAuthError,
},
{
name: "Wrapped WRONGPASS error",
errorMsg: "WRONGPASS invalid username-password pair",
checkFunc: IsAuthError,
},
{
name: "Wrapped unauthenticated error",
errorMsg: "ERR unauthenticated",
checkFunc: IsAuthError,
},
{
name: "Wrapped NOPERM error",
errorMsg: "NOPERM this user has no permissions to run the 'flushdb' command",
checkFunc: IsPermissionError,
},
{
name: "Wrapped EXECABORT error",
errorMsg: "EXECABORT Transaction discarded because of previous errors",
checkFunc: IsExecAbortError,
},
{
name: "Wrapped OOM error",
errorMsg: "OOM command not allowed when used memory > 'maxmemory'",
checkFunc: IsOOMError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create the typed error
err := parseTypedRedisError(tt.errorMsg)
// Wrap it multiple times (simulating hook wrapping)
wrappedErr := fmt.Errorf("hook error: %w", err)
doubleWrappedErr := fmt.Errorf("another wrapper: %w", wrappedErr)
// Check that the helper function still works with wrapped errors
if !tt.checkFunc(doubleWrappedErr) {
t.Errorf("Helper function failed to detect wrapped error: %v", doubleWrappedErr)
}
// Verify the original error message is still accessible
if !errors.Is(doubleWrappedErr, err) {
t.Errorf("errors.Is failed to match wrapped error")
}
})
}
}
// TestMovedAndAskErrorAddressExtraction tests address extraction from MOVED/ASK errors
func TestMovedAndAskErrorAddressExtraction(t *testing.T) {
tests := []struct {
name string
errorMsg string
expectedAddr string
}{
{
name: "MOVED with IP address",
errorMsg: "MOVED 3999 127.0.0.1:6381",
expectedAddr: "127.0.0.1:6381",
},
{
name: "MOVED with hostname",
errorMsg: "MOVED 3999 redis-node-1:6379",
expectedAddr: "redis-node-1:6379",
},
{
name: "ASK with IP address",
errorMsg: "ASK 3999 192.168.1.100:6380",
expectedAddr: "192.168.1.100:6380",
},
{
name: "ASK with hostname",
errorMsg: "ASK 3999 redis-node-2:6379",
expectedAddr: "redis-node-2:6379",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parseTypedRedisError(tt.errorMsg)
var addr string
if movedErr, ok := IsMovedError(err); ok {
addr = movedErr.Addr()
} else if askErr, ok := IsAskError(err); ok {
addr = askErr.Addr()
} else {
t.Fatalf("Error is neither MOVED nor ASK: %v", err)
}
if addr != tt.expectedAddr {
t.Errorf("Address mismatch: got %q, want %q", addr, tt.expectedAddr)
}
// Test with wrapped error
wrappedErr := fmt.Errorf("wrapped: %w", err)
if movedErr, ok := IsMovedError(wrappedErr); ok {
addr = movedErr.Addr()
} else if askErr, ok := IsAskError(wrappedErr); ok {
addr = askErr.Addr()
} else {
t.Fatalf("Wrapped error is neither MOVED nor ASK: %v", wrappedErr)
}
if addr != tt.expectedAddr {
t.Errorf("Address mismatch in wrapped error: got %q, want %q", addr, tt.expectedAddr)
}
})
}
}
// TestGenericRedisError tests that unknown Redis errors fall back to generic RedisError
func TestGenericRedisError(t *testing.T) {
tests := []struct {
name string
errorMsg string
}{
{
name: "Generic error",
errorMsg: "ERR unknown command",
},
{
name: "WRONGTYPE error",
errorMsg: "WRONGTYPE Operation against a key holding the wrong kind of value",
},
{
name: "BUSYKEY error",
errorMsg: "BUSYKEY Target key name already exists",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parseTypedRedisError(tt.errorMsg)
// Should be a generic RedisError
if _, ok := err.(RedisError); !ok {
t.Errorf("Expected RedisError, got %T", err)
}
// Should preserve the error message
if err.Error() != tt.errorMsg {
t.Errorf("Error message mismatch: got %q, want %q", err.Error(), tt.errorMsg)
}
// Should not match any typed error checks
if IsLoadingError(err) || IsReadOnlyError(err) || IsClusterDownError(err) ||
IsTryAgainError(err) || IsMasterDownError(err) || IsMaxClientsError(err) ||
IsAuthError(err) || IsPermissionError(err) || IsExecAbortError(err) || IsOOMError(err) {
t.Errorf("Generic error incorrectly matched a typed error check")
}
})
}
}
// TestBackwardCompatibility tests that error messages remain unchanged
func TestBackwardCompatibility(t *testing.T) {
// This test ensures that the error messages are exactly the same as before
// to maintain backward compatibility with code that checks error messages
tests := []struct {
input string
expected string
}{
{"LOADING Redis is loading the dataset in memory", "LOADING Redis is loading the dataset in memory"},
{"READONLY You can't write against a read only replica", "READONLY You can't write against a read only replica"},
{"MOVED 3999 127.0.0.1:6381", "MOVED 3999 127.0.0.1:6381"},
{"ASK 3999 127.0.0.1:6381", "ASK 3999 127.0.0.1:6381"},
{"CLUSTERDOWN The cluster is down", "CLUSTERDOWN The cluster is down"},
{"TRYAGAIN Multiple keys request during rehashing of slot", "TRYAGAIN Multiple keys request during rehashing of slot"},
{"MASTERDOWN Link with MASTER is down", "MASTERDOWN Link with MASTER is down"},
{"ERR max number of clients reached", "ERR max number of clients reached"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
err := parseTypedRedisError(tt.input)
if err.Error() != tt.expected {
t.Errorf("Error message changed! Got %q, want %q", err.Error(), tt.expected)
}
})
}
}

View File

@@ -355,6 +355,10 @@ func (opt *Options) init() {
opt.MaxRetryBackoff = 512 * time.Millisecond
}
if opt.FailingTimeoutSeconds == 0 {
opt.FailingTimeoutSeconds = 15
}
opt.MaintNotificationsConfig = opt.MaintNotificationsConfig.ApplyDefaultsWithPoolConfig(opt.PoolSize, opt.MaxActiveConns)
// auto-detect endpoint type if not specified

View File

@@ -145,25 +145,31 @@ func IsHandlerNilError(err error) bool {
return errors.Is(err, ErrHandlerNil)
}
// IsHandlerExistsError checks if an error is due to attempting to overwrite an existing handler
// IsHandlerExistsError checks if an error is due to attempting to overwrite an existing handler.
// This function works correctly even when the error is wrapped.
func IsHandlerExistsError(err error) bool {
if handlerErr, ok := err.(*HandlerError); ok {
var handlerErr *HandlerError
if errors.As(err, &handlerErr) {
return handlerErr.Operation == ProcessorOperationRegister && handlerErr.Reason == ReasonHandlerExists
}
return false
}
// IsProtectedHandlerError checks if an error is due to attempting to unregister a protected handler
// IsProtectedHandlerError checks if an error is due to attempting to unregister a protected handler.
// This function works correctly even when the error is wrapped.
func IsProtectedHandlerError(err error) bool {
if handlerErr, ok := err.(*HandlerError); ok {
var handlerErr *HandlerError
if errors.As(err, &handlerErr) {
return handlerErr.Operation == ProcessorOperationUnregister && handlerErr.Reason == ReasonHandlerProtected
}
return false
}
// IsVoidProcessorError checks if an error is due to void processor operations
// IsVoidProcessorError checks if an error is due to void processor operations.
// This function works correctly even when the error is wrapped.
func IsVoidProcessorError(err error) bool {
if procErr, ok := err.(*ProcessorError); ok {
var procErr *ProcessorError
if errors.As(err, &procErr) {
return procErr.ProcessorType == ProcessorTypeVoidProcessor && procErr.Reason == ReasonPushNotificationsDisabled
}
return false

View File

@@ -2,6 +2,7 @@ package push
import (
"context"
"fmt"
"testing"
)
@@ -313,3 +314,85 @@ func (h *UnitTestHandler) Reset() {
h.lastNotification = nil
h.errorToReturn = nil
}
// TestErrorWrapping tests that error checking functions work with wrapped errors
func TestErrorWrapping(t *testing.T) {
t.Run("IsHandlerExistsError with wrapped error", func(t *testing.T) {
// Create a HandlerError
handlerErr := ErrHandlerExists("test-notification")
// Wrap it
wrappedErr := fmt.Errorf("operation failed: %w", handlerErr)
doubleWrappedErr := fmt.Errorf("context: %w", wrappedErr)
// Should still be detected through wrapping
if !IsHandlerExistsError(doubleWrappedErr) {
t.Errorf("IsHandlerExistsError should detect wrapped error")
}
// Verify it doesn't match other error types
if IsProtectedHandlerError(doubleWrappedErr) {
t.Errorf("IsProtectedHandlerError should not match handler exists error")
}
})
t.Run("IsProtectedHandlerError with wrapped error", func(t *testing.T) {
// Create a protected handler error
protectedErr := ErrProtectedHandler("protected-notification")
// Wrap it
wrappedErr := fmt.Errorf("unregister failed: %w", protectedErr)
// Should still be detected through wrapping
if !IsProtectedHandlerError(wrappedErr) {
t.Errorf("IsProtectedHandlerError should detect wrapped error")
}
// Verify it doesn't match other error types
if IsHandlerExistsError(wrappedErr) {
t.Errorf("IsHandlerExistsError should not match protected handler error")
}
})
t.Run("IsVoidProcessorError with wrapped error", func(t *testing.T) {
// Create a void processor error
voidErr := ErrVoidProcessorRegister("test-notification")
// Wrap it multiple times
wrappedErr := fmt.Errorf("register failed: %w", voidErr)
doubleWrappedErr := fmt.Errorf("processor error: %w", wrappedErr)
// Should still be detected through wrapping
if !IsVoidProcessorError(doubleWrappedErr) {
t.Errorf("IsVoidProcessorError should detect wrapped error")
}
})
t.Run("IsHandlerNilError with wrapped error", func(t *testing.T) {
// Wrap the nil handler error
wrappedErr := fmt.Errorf("validation failed: %w", ErrHandlerNil)
// Should still be detected through wrapping
if !IsHandlerNilError(wrappedErr) {
t.Errorf("IsHandlerNilError should detect wrapped error")
}
})
t.Run("Error functions return false for non-matching errors", func(t *testing.T) {
// Create a different error
otherErr := fmt.Errorf("some other error")
if IsHandlerExistsError(otherErr) {
t.Errorf("IsHandlerExistsError should return false for non-matching error")
}
if IsProtectedHandlerError(otherErr) {
t.Errorf("IsProtectedHandlerError should return false for non-matching error")
}
if IsVoidProcessorError(otherErr) {
t.Errorf("IsVoidProcessorError should return false for non-matching error")
}
if IsHandlerNilError(otherErr) {
t.Errorf("IsHandlerNilError should return false for non-matching error")
}
})
}

View File

@@ -2,5 +2,5 @@ package redis
// Version is the current release version.
func Version() string {
return "9.16.0"
return "9.17.0"
}