package redis import ( "context" "encoding/json" "fmt" "math" "os" "strings" "time" "gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/errors" "gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/logs" "github.com/go-redis/redis_rate/v9" "github.com/go-redis/redis/v8" ) var ( ctx = context.Background() redisClient *ClientWithHelpers ) type ClientWithHelpers struct { Client *redis.Client Available bool } type SetLockKeyOptions struct { Value *string MaxRetries *int RetryInterval *time.Duration FallbackResult *bool } func GetRedisClient(isDebug bool) *ClientWithHelpers { if redisClient != nil && redisClient.IsConnected() { return redisClient } redisClient = connectToRedis(isDebug) return redisClient } func connectToRedis(isDebug bool) *ClientWithHelpers { if os.Getenv("REDIS_HOST") != "false" { host := os.Getenv("REDIS_HOST") port := os.Getenv("REDIS_PORT") if isDebug { env := os.Getenv("ENVIRONMENT") switch env { case "dev": port = "6380" case "stage": port = "6381" case "sandbox", "qa": port = "6382" case "prod": port = "6383" } } return NewClient(host + ":" + port) } return &ClientWithHelpers{ Client: nil, Available: false, } } func NewClient(addr string) *ClientWithHelpers { return &ClientWithHelpers{ Client: redis.NewClient(&redis.Options{ MaxRetries: 1, DialTimeout: time.Duration(1) * time.Second, // So max 2 second wait Addr: addr, Password: "", // no password set DB: 0, // use default Db }), Available: true, } } func (r ClientWithHelpers) IsConnected() bool { return r.Client != nil && r.Available == true } func (r ClientWithHelpers) DeleteByKey(key string) error { if !r.IsConnected() { return errors.Errorf("REDIS disabled: cannot del key(%s)", key) } _, err := r.Client.Del(ctx, key).Result() if err != nil { return errors.Wrapf(err, "failed to del key(%s)", key) } return nil } func (r ClientWithHelpers) DeleteByKeyPattern(pattern string) { if !r.IsConnected() { return } iter := r.Client.Scan(ctx, 0, pattern, math.MaxInt64).Iterator() for iter.Next(ctx) { val := iter.Val() err := r.Client.Del(ctx, val).Err() if err != nil { panic(err) } } if err := iter.Err(); err != nil { panic(err) } } func (r ClientWithHelpers) SetObjectByKey(key string, object interface{}) { if !r.IsConnected() { return } jsonBytes, err := json.Marshal(object) if err != nil { logs.ErrorWithMsg("Error marshalling object to Redis: %s", err) return } _, err = r.Client.Set(ctx, key, string(jsonBytes), 24*time.Hour).Result() if err != nil { logs.ErrorWithMsg(fmt.Sprintf("Error setting value to Redis for key: %s", key), err) /* Prevent further calls in this execution from trying to connect and also timeout */ if strings.HasSuffix(err.Error(), "i/o timeout") { r.Available = false } } } func (r ClientWithHelpers) SetObjectByKeyWithExpiry(key string, object interface{}, expiration time.Duration) { if !r.IsConnected() { return } jsonBytes, err := json.Marshal(object) if err != nil { logs.ErrorWithMsg("Error marshalling object to Redis: %s", err) return } _, err = r.Client.Set(ctx, key, string(jsonBytes), expiration).Result() if err != nil { logs.ErrorWithMsg(fmt.Sprintf("Error setting value to Redis for key: %s", key), err) /* Prevent further calls in this execution from trying to connect and also timeout */ if strings.HasSuffix(err.Error(), "i/o timeout") { r.Available = false } } } func (r ClientWithHelpers) SetObjectByKeyIndefinitely(key string, object interface{}) { if !r.IsConnected() { return } jsonBytes, err := json.Marshal(object) if err != nil { logs.ErrorWithMsg("Error marshalling object to Redis", err) return } _, err = r.Client.Set(ctx, key, string(jsonBytes), 0).Result() if err != nil { logs.ErrorWithMsg(fmt.Sprintf("Error setting value to Redis for key: %s", key), err) /* Prevent further calls in this execution from trying to connect and also timeout */ if strings.HasSuffix(err.Error(), "i/o timeout") { r.Available = false } } } // GetObjectByKey fetches an object from Redis by key. If the key does not exist or there is an error, it returns nil. // If an expiry is provided, it will both fetch the key and update its expiry. func GetObjectByKey[T any](redisClient *ClientWithHelpers, key string, object T, expiry ...time.Duration) *T { // Make sure we have a Redis client, and it is connected if redisClient == nil || !redisClient.IsConnected() { return nil } // Get the object from Redis jsonString := redisClient.GetValueByKey(key, expiry...) if jsonString == "" { return nil } // Now unmarshal err := json.Unmarshal([]byte(jsonString), &object) if err != nil { return nil } return &object } // GetValueByKey fetches a value from Redis by key. If the key does not exist or there is an error, it returns an empty // string. If an expiry is provided, it will both fetch the key and update its expiry. func (r ClientWithHelpers) GetValueByKey(key string, expiry ...time.Duration) string { if !r.IsConnected() { return "" } var jsonString string var err error if len(expiry) > 0 { jsonString, err = r.Client.GetEx(ctx, key, expiry[0]).Result() } else { jsonString, err = r.Client.Get(ctx, key).Result() } if err == redis.Nil { /* Key does not exist */ return "" } else if err != nil { /* Actual error */ logs.Warn(fmt.Sprintf("Error fetching object from Redis for key: %s", key), err) /* Prevent further calls in this execution from trying to connect and also timeout */ if strings.HasSuffix(err.Error(), "i/o timeout") { r.Available = false } } return jsonString } func (r ClientWithHelpers) RateLimit(key string, limitFn func(int) redis_rate.Limit, limit int) (bool, error) { limiter := redis_rate.NewLimiter(r.Client) res, err := limiter.Allow(ctx, key, limitFn(limit)) if err != nil { logs.ErrorWithMsg(fmt.Sprintf("Redis Error rate limiting - %s", key), err) /* Prevent further calls in this execution from trying to connect and also timeout */ if strings.HasSuffix(err.Error(), "i/o timeout") { r.Available = false } return false, err } else { return res.Allowed == 1, nil } } // SetLockKey attempts to set a lock on the specified key. func (r ClientWithHelpers) SetLockKey(key string, expiration time.Duration, lockOptions ...SetLockKeyOptions) (success bool, err error) { fallbackResult := false if len(lockOptions) > 0 && lockOptions[0].FallbackResult != nil { fallbackResult = *lockOptions[0].FallbackResult } if !r.IsConnected() { return fallbackResult, errors.Error("Redis is not connected") } value := "1" retries := 0 retryInterval := 100 * time.Millisecond if len(lockOptions) > 0 { // Only use the values that were set if lockOptions[0].Value != nil { value = *lockOptions[0].Value } if lockOptions[0].MaxRetries != nil { retries = *lockOptions[0].MaxRetries } if lockOptions[0].RetryInterval != nil { retryInterval = *lockOptions[0].RetryInterval } } for retries >= 0 { success, err = r.Client.SetNX(ctx, key, value, expiration).Result() if err != nil { logs.ErrorWithMsg(fmt.Sprintf("Error setting lock key %s", key), err) // Prevent further calls in this execution from trying to connect and also timeout if strings.HasSuffix(err.Error(), "i/o timeout") { r.Available = false } return fallbackResult, err } if success || retries == 0 { break } retries-- time.Sleep(retryInterval) } return success, nil } func (r ClientWithHelpers) KeepLockKeyAlive(key string, expiration time.Duration) { if !r.IsConnected() { return } _ = r.Client.Expire(ctx, key, expiration) } func (r ClientWithHelpers) IncrementCounter(key string) *int64 { if !r.IsConnected() { return nil } val, err := r.Client.Incr(ctx, key).Result() if err != nil { return nil } return &val } func (r ClientWithHelpers) DecrementCounter(key string) *int64 { if !r.IsConnected() { return nil } val, err := r.Client.Decr(ctx, key).Result() if err != nil { return nil } return &val }