feat(config): use REDIS_URL for redis

This commit is contained in:
tiennm99 committed 2026-06-22 09:19:25 +07:00
1 parent f022e8463c
commit 204a76a32d
8 files changed
+82 -50

No files matched your search

+1 -3
View File
@@ -1,7 +1,5 @@
TELEGRAM_BOT_TOKEN=
REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_URL=redis://localhost:6379/0
OPENAI_STATUS_BASE_URL=https://status.openai.com
POLL_INTERVAL=1m
HTTP_TIMEOUT=10s
+3 -3
View File
@@ -48,14 +48,14 @@ go run ./cmd/openai-status-bot
| Variable | Default | Description |
|----------|---------|-------------|
| `TELEGRAM_BOT_TOKEN` | required | Telegram bot token from BotFather |
| `REDIS_ADDR` | `localhost:6379` | Redis address |
| `REDIS_PASSWORD` | empty | Redis password |
| `REDIS_DB` | `0` | Redis database number, valid `0`-`15` |
| `REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL, e.g. `redis://:password@localhost:6379/0` |
| `OPENAI_STATUS_BASE_URL` | `https://status.openai.com` | OpenAI status page base URL |
| `POLL_INTERVAL` | `1m` | Status check interval, valid `5s`-`1h` |
| `HTTP_TIMEOUT` | `10s` | HTTP request timeout, valid `1s`-`5m` |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`/`warning`, or `error` |
Percent-encode Redis usernames or passwords that contain URL-reserved characters such as `@`, `:`, `/`, `#`, or `%`.
## Notes
The first successful poll seeds Redis and does not send historical incidents. Notifications start from later changes.
+2 -6
View File
@@ -31,13 +31,9 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
redisClient := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddr,
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
})
redisClient := redis.NewClient(cfg.RedisOptions)
if err := redisClient.Ping(ctx).Err(); err != nil {
logger.Error("connect redis", "addr", cfg.RedisAddr, "error", err)
logger.Error("connect redis", "network", cfg.RedisOptions.Network, "addr", cfg.RedisOptions.Addr, "db", cfg.RedisOptions.DB, "tls", cfg.RedisOptions.TLSConfig != nil, "error", err)
os.Exit(1)
}
defer redisClient.Close()
+1 -1
View File
@@ -12,7 +12,7 @@ services:
env_file:
- .env
environment:
REDIS_ADDR: redis:6379
REDIS_URL: redis://redis:6379/0
depends_on:
- redis
restart: unless-stopped
+3 -1
View File
@@ -11,6 +11,7 @@
```bash
cp .env.example .env
# set TELEGRAM_BOT_TOKEN in .env
# REDIS_URL is overridden to redis://redis:6379/0 by docker compose
docker compose up --build
```
@@ -27,6 +28,7 @@ Run bot:
```bash
cp .env.example .env
# export TELEGRAM_BOT_TOKEN or source .env with your shell workflow
# REDIS_URL defaults to redis://localhost:6379/0
go run ./cmd/openai-status-bot
```
@@ -34,7 +36,7 @@ go run ./cmd/openai-status-bot
```bash
go test ./...
go build ./cmd/openai-status-bot
go build -o /tmp/openai-status-bot ./cmd/openai-status-bot
```
Then send `/start` to the Telegram bot and run `/status`. If the same token was previously used by a webhook deployment, startup clears that webhook before long polling begins.
+1 -1
View File
@@ -38,7 +38,7 @@ Subscriber set members are `chatID` or `chatID:threadID`. Missing settings defau
## Runtime
The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, which allows migration from a webhook-based deployment using the same bot token. Docker Compose starts Redis and the bot.
The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, which allows migration from a webhook-based deployment using the same bot token. Redis is configured with `REDIS_URL`. Docker Compose starts Redis and the bot.
## Failure Behavior
+19 -26
View File
@@ -5,16 +5,15 @@ import (
"log/slog"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
type Config struct {
TelegramBotToken string
RedisAddr string
RedisPassword string
RedisDB int
RedisOptions *redis.Options
OpenAIStatusBaseURL string
PollInterval time.Duration
HTTPTimeout time.Duration
@@ -33,22 +32,20 @@ const (
func LoadFromEnv() (Config, error) {
cfg := Config{
TelegramBotToken: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
RedisPassword: strings.TrimSpace(os.Getenv("REDIS_PASSWORD")),
OpenAIStatusBaseURL: strings.TrimRight(getEnv("OPENAI_STATUS_BASE_URL", "https://status.openai.com"), "/"),
}
if cfg.TelegramBotToken == "" {
return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN is required")
}
var err error
cfg.RedisOptions, err = parseRedisURL("REDIS_URL", getEnv("REDIS_URL", "redis://localhost:6379/0"))
if err != nil {
return Config{}, err
}
if err := validateBaseURL("OPENAI_STATUS_BASE_URL", cfg.OpenAIStatusBaseURL); err != nil {
return Config{}, err
}
var err error
cfg.RedisDB, err = parseBoundedIntEnv("REDIS_DB", 0, minRedisDB, maxRedisDB)
if err != nil {
return Config{}, err
}
cfg.PollInterval, err = parseDurationEnv("POLL_INTERVAL", time.Minute, minPollInterval, maxPollInterval)
if err != nil {
return Config{}, err
@@ -72,21 +69,6 @@ func getEnv(key, fallback string) string {
return fallback
}
func parseBoundedIntEnv(key string, fallback, minValue, maxValue int) (int, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer: %w", key, err)
}
if parsed < minValue || parsed > maxValue {
return 0, fmt.Errorf("%s must be between %d and %d", key, minValue, maxValue)
}
return parsed, nil
}
func parseDurationEnv(key string, fallback, minValue, maxValue time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
@@ -102,6 +84,17 @@ func parseDurationEnv(key string, fallback, minValue, maxValue time.Duration) (t
return parsed, nil
}
func parseRedisURL(key, value string) (*redis.Options, error) {
options, err := redis.ParseURL(value)
if err != nil {
return nil, fmt.Errorf("%s must be a valid Redis URL", key)
}
if options.DB < minRedisDB || options.DB > maxRedisDB {
return nil, fmt.Errorf("%s database must be between %d and %d", key, minRedisDB, maxRedisDB)
}
return options, nil
}
func validateBaseURL(key, value string) error {
parsed, err := url.Parse(value)
if err != nil {
+52 -9
View File
@@ -10,7 +10,6 @@ import (
func TestLoadFromEnvDefaultsAndTrimming(t *testing.T) {
setMinimalEnv(t)
t.Setenv("TELEGRAM_BOT_TOKEN", " token ")
t.Setenv("REDIS_PASSWORD", " redis-pass ")
cfg, err := LoadFromEnv()
if err != nil {
@@ -19,14 +18,58 @@ func TestLoadFromEnvDefaultsAndTrimming(t *testing.T) {
if cfg.TelegramBotToken != "token" {
t.Fatalf("TelegramBotToken = %q", cfg.TelegramBotToken)
}
if cfg.RedisPassword != "redis-pass" {
t.Fatalf("RedisPassword = %q", cfg.RedisPassword)
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6379" || cfg.RedisOptions.DB != 0 {
t.Fatalf("unexpected Redis options: %+v", cfg.RedisOptions)
}
if cfg.RedisDB != 0 || cfg.PollInterval != time.Minute || cfg.HTTPTimeout != 10*time.Second {
if cfg.PollInterval != time.Minute || cfg.HTTPTimeout != 10*time.Second {
t.Fatalf("unexpected defaults: %+v", cfg)
}
}
func TestLoadFromEnvIgnoresLegacyRedisVariables(t *testing.T) {
setMinimalEnv(t)
t.Setenv("REDIS_ADDR", "legacy-host:6380")
t.Setenv("REDIS_PASSWORD", "legacy-password")
t.Setenv("REDIS_DB", "5")
cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv returned error: %v", err)
}
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6379" || cfg.RedisOptions.Password != "" || cfg.RedisOptions.DB != 0 {
t.Fatalf("legacy Redis variables affected options: %+v", cfg.RedisOptions)
}
}
func TestLoadFromEnvAcceptsRedisURLCredentials(t *testing.T) {
setMinimalEnv(t)
t.Setenv("REDIS_URL", " redis://user:redis%40pass%3Awith%2Freserved@localhost:6380/2 ")
cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv returned error: %v", err)
}
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6380" || cfg.RedisOptions.Username != "user" || cfg.RedisOptions.Password != "redis@pass:with/reserved" || cfg.RedisOptions.DB != 2 {
t.Fatalf("unexpected Redis options: %+v", cfg.RedisOptions)
}
}
func TestLoadFromEnvDoesNotLeakRedisURLCredentialsInParseErrors(t *testing.T) {
setMinimalEnv(t)
t.Setenv("REDIS_URL", "redis://:secret%zz@localhost:6379/0")
_, err := LoadFromEnv()
if err == nil {
t.Fatal("expected error")
}
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "secret%zz") {
t.Fatalf("error leaked Redis URL credentials: %q", err.Error())
}
if !strings.Contains(err.Error(), "REDIS_URL must be a valid Redis URL") {
t.Fatalf("error = %q, want generic Redis URL error", err.Error())
}
}
func TestLoadFromEnvAcceptsWarningLogAlias(t *testing.T) {
setMinimalEnv(t)
t.Setenv("LOG_LEVEL", "warning")
@@ -47,8 +90,10 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
value string
wantErr string
}{
{name: "redis db below range", key: "REDIS_DB", value: "-1", wantErr: "REDIS_DB must be between 0 and 15"},
{name: "redis db above range", key: "REDIS_DB", value: "16", wantErr: "REDIS_DB must be between 0 and 15"},
{name: "redis url missing scheme", key: "REDIS_URL", value: "localhost:6379", wantErr: "REDIS_URL must be a valid Redis URL"},
{name: "redis url invalid db", key: "REDIS_URL", value: "redis://localhost:6379/not-a-number", wantErr: "REDIS_URL must be a valid Redis URL"},
{name: "redis url db below range", key: "REDIS_URL", value: "redis://localhost:6379/-1", wantErr: "REDIS_URL database must be between 0 and 15"},
{name: "redis url db above range", key: "REDIS_URL", value: "redis://localhost:6379/16", wantErr: "REDIS_URL database must be between 0 and 15"},
{name: "poll interval too small", key: "POLL_INTERVAL", value: "1ns", wantErr: "POLL_INTERVAL must be between 5s and 1h0m0s"},
{name: "http timeout too small", key: "HTTP_TIMEOUT", value: "500ms", wantErr: "HTTP_TIMEOUT must be between 1s and 5m0s"},
{name: "base url missing scheme", key: "OPENAI_STATUS_BASE_URL", value: "status.openai.com", wantErr: "OPENAI_STATUS_BASE_URL must use http or https"},
@@ -77,9 +122,7 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
func setMinimalEnv(t *testing.T) {
t.Helper()
t.Setenv("TELEGRAM_BOT_TOKEN", "token")
t.Setenv("REDIS_ADDR", "")
t.Setenv("REDIS_PASSWORD", "")
t.Setenv("REDIS_DB", "")
t.Setenv("REDIS_URL", "")
t.Setenv("OPENAI_STATUS_BASE_URL", "")
t.Setenv("POLL_INTERVAL", "")
t.Setenv("HTTP_TIMEOUT", "")