Skip to content

Commit 6c24f60

Browse files
ndyakovCopilot
andauthored
feat(errors): Introduce typed errors (#3602)
* typed errors * add error documentation * backwards compatibility * update readme, remove Is methods * Update internal/proto/redis_errors.go Co-authored-by: Copilot <[email protected]> * Update internal/proto/redis_errors.go Co-authored-by: Copilot <[email protected]> * support error wrapping for io and context errors * use unwrapping of errors in push for consistency * add common error types * fix test * fix flaky test * add comments in the example --------- Co-authored-by: Copilot <[email protected]>
1 parent 817e624 commit 6c24f60

File tree

11 files changed

+2067
-66
lines changed

11 files changed

+2067
-66
lines changed

README.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,144 @@ vals, err := rdb.Eval(ctx, "return {KEYS[1],ARGV[1]}", []string{"key"}, "hello")
429429
res, err := rdb.Do(ctx, "set", "key", "value").Result()
430430
```
431431
432+
## Typed Errors
433+
434+
go-redis provides typed error checking functions for common Redis errors:
435+
436+
```go
437+
// Cluster and replication errors
438+
redis.IsLoadingError(err) // Redis is loading the dataset
439+
redis.IsReadOnlyError(err) // Write to read-only replica
440+
redis.IsClusterDownError(err) // Cluster is down
441+
redis.IsTryAgainError(err) // Command should be retried
442+
redis.IsMasterDownError(err) // Master is down
443+
redis.IsMovedError(err) // Returns (address, true) if key moved
444+
redis.IsAskError(err) // Returns (address, true) if key being migrated
445+
446+
// Connection and resource errors
447+
redis.IsMaxClientsError(err) // Maximum clients reached
448+
redis.IsAuthError(err) // Authentication failed (NOAUTH, WRONGPASS, unauthenticated)
449+
redis.IsPermissionError(err) // Permission denied (NOPERM)
450+
redis.IsOOMError(err) // Out of memory (OOM)
451+
452+
// Transaction errors
453+
redis.IsExecAbortError(err) // Transaction aborted (EXECABORT)
454+
```
455+
456+
### Error Wrapping in Hooks
457+
458+
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:
459+
460+
```go
461+
// Custom error type (preferred)
462+
type AppError struct {
463+
Code string
464+
RequestID string
465+
Err error
466+
}
467+
468+
func (e *AppError) Error() string {
469+
return fmt.Sprintf("[%s] request_id=%s: %v", e.Code, e.RequestID, e.Err)
470+
}
471+
472+
func (e *AppError) Unwrap() error {
473+
return e.Err
474+
}
475+
476+
// Hook implementation
477+
func (h MyHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
478+
return func(ctx context.Context, cmd redis.Cmder) error {
479+
err := next(ctx, cmd)
480+
if err != nil {
481+
// Wrap with custom error type
482+
wrappedErr := &AppError{
483+
Code: "REDIS_ERROR",
484+
RequestID: getRequestID(ctx),
485+
Err: err,
486+
}
487+
cmd.SetErr(wrappedErr)
488+
return wrappedErr // Return wrapped error to preserve it
489+
}
490+
return nil
491+
}
492+
}
493+
494+
// Typed error detection works through wrappers
495+
if redis.IsLoadingError(err) {
496+
// Retry logic
497+
}
498+
499+
// Extract custom error if needed
500+
var appErr *AppError
501+
if errors.As(err, &appErr) {
502+
log.Printf("Request: %s", appErr.RequestID)
503+
}
504+
```
505+
506+
Alternatively, use `fmt.Errorf` with `%w`:
507+
```go
508+
wrappedErr := fmt.Errorf("context: %w", err)
509+
cmd.SetErr(wrappedErr)
510+
```
511+
512+
### Pipeline Hook Example
513+
514+
For pipeline operations, use `ProcessPipelineHook`:
515+
516+
```go
517+
type PipelineLoggingHook struct{}
518+
519+
func (h PipelineLoggingHook) DialHook(next redis.DialHook) redis.DialHook {
520+
return next
521+
}
522+
523+
func (h PipelineLoggingHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
524+
return next
525+
}
526+
527+
func (h PipelineLoggingHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
528+
return func(ctx context.Context, cmds []redis.Cmder) error {
529+
start := time.Now()
530+
531+
// Execute the pipeline
532+
err := next(ctx, cmds)
533+
534+
duration := time.Since(start)
535+
log.Printf("Pipeline executed %d commands in %v", len(cmds), duration)
536+
537+
// Process individual command errors
538+
// Note: Individual command errors are already set on each cmd by the pipeline execution
539+
for _, cmd := range cmds {
540+
if cmdErr := cmd.Err(); cmdErr != nil {
541+
// Check for specific error types using typed error functions
542+
if redis.IsAuthError(cmdErr) {
543+
log.Printf("Auth error in pipeline command %s: %v", cmd.Name(), cmdErr)
544+
} else if redis.IsPermissionError(cmdErr) {
545+
log.Printf("Permission error in pipeline command %s: %v", cmd.Name(), cmdErr)
546+
}
547+
548+
// Optionally wrap individual command errors to add context
549+
// The wrapped error preserves type information through errors.As()
550+
wrappedErr := fmt.Errorf("pipeline cmd %s failed: %w", cmd.Name(), cmdErr)
551+
cmd.SetErr(wrappedErr)
552+
}
553+
}
554+
555+
// Return the pipeline-level error (connection errors, etc.)
556+
// You can wrap it if needed, or return it as-is
557+
return err
558+
}
559+
}
560+
561+
// Register the hook
562+
rdb.AddHook(PipelineLoggingHook{})
563+
564+
// Use pipeline - errors are still properly typed
565+
pipe := rdb.Pipeline()
566+
pipe.Set(ctx, "key1", "value1", 0)
567+
pipe.Get(ctx, "key2")
568+
_, err := pipe.Exec(ctx)
569+
```
432570
433571
## Run the test
434572

acl_commands_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ var _ = Describe("ACL permissions", Label("NonRedisEnterprise"), func() {
306306

307307
// no perm for dropindex
308308
err = c.FTDropIndex(ctx, "txt").Err()
309-
Expect(err).ToNot(BeEmpty())
309+
Expect(err).To(HaveOccurred())
310310
Expect(err.Error()).To(ContainSubstring("NOPERM"))
311311

312312
// json set and get have perm
@@ -315,15 +315,15 @@ var _ = Describe("ACL permissions", Label("NonRedisEnterprise"), func() {
315315

316316
// no perm for json clear
317317
err = c.JSONClear(ctx, "foo", "$").Err()
318-
Expect(err).ToNot(BeEmpty())
318+
Expect(err).To(HaveOccurred())
319319
Expect(err.Error()).To(ContainSubstring("NOPERM"))
320320

321321
// perm for reserve
322322
Expect(c.BFReserve(ctx, "bloom", 0.01, 100).Err()).NotTo(HaveOccurred())
323323

324324
// no perm for info
325325
err = c.BFInfo(ctx, "bloom").Err()
326-
Expect(err).ToNot(BeEmpty())
326+
Expect(err).To(HaveOccurred())
327327
Expect(err.Error()).To(ContainSubstring("NOPERM"))
328328

329329
// perm for cf.reserve
@@ -338,7 +338,7 @@ var _ = Describe("ACL permissions", Label("NonRedisEnterprise"), func() {
338338
Expect(c.TSCreate(ctx, "tsts").Err()).NotTo(HaveOccurred())
339339
// noperm for ts.info
340340
err = c.TSInfo(ctx, "tsts").Err()
341-
Expect(err).ToNot(BeEmpty())
341+
Expect(err).To(HaveOccurred())
342342
Expect(err.Error()).To(ContainSubstring("NOPERM"))
343343

344344
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())

0 commit comments

Comments
 (0)