From 0f0adac82a2acaab59030118d37944b78b2544cf Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Fri, 10 Oct 2025 22:38:09 +0200 Subject: [PATCH] Add configurable timeouts and cache validity to ESI and SSO components --- esi/cached.go | 68 ++++++++++++++++++++-------------------------- esi/client.go | 17 ++++++++---- esi/sso.go | 18 ++++++++---- options/options.go | 14 +++++++++- webhook/zulip.go | 11 +++++++- 5 files changed, 78 insertions(+), 50 deletions(-) diff --git a/esi/cached.go b/esi/cached.go index 330d434..f96ce3a 100644 --- a/esi/cached.go +++ b/esi/cached.go @@ -8,6 +8,7 @@ import ( "fmt" "time" + "go-eve-pi/options" "go-eve-pi/types" logger "git.site.quack-lab.dev/dave/cylogger" @@ -22,6 +23,7 @@ type CachedESI struct { GetCacheEntry(urlHash string) (*types.CacheEntry, error) SaveCacheEntry(entry *types.CacheEntry) error } + cacheValidity time.Duration } // NewCachedESI creates a new CachedESI instance @@ -29,44 +31,43 @@ func NewCachedESI(direct ESIInterface, db interface { GetCacheEntry(urlHash string) (*types.CacheEntry, error) SaveCacheEntry(entry *types.CacheEntry) error }) *CachedESI { + // Parse cache validity ONCE at initialization + cacheValidity, err := time.ParseDuration(options.GlobalOptions.CacheValidity) + if err != nil { + logger.Warning("Invalid cache validity duration %s, using 10m default: %v", options.GlobalOptions.CacheValidity, err) + cacheValidity = 10 * time.Minute + } + return &CachedESI{ - direct: direct, - db: db, + direct: direct, + db: db, + cacheValidity: cacheValidity, } } // GetCharacterPlanets retrieves a list of planets for a character with caching func (c *CachedESI) GetCharacterPlanets(ctx context.Context, characterID int, accessToken string) ([]Planet, error) { url := fmt.Sprintf("/v1/characters/%d/planets/", characterID) - result, err := func() (interface{}, error) { - var fetchFunc func() (interface{}, error) = func() (interface{}, error) { - return c.direct.GetCharacterPlanets(ctx, characterID, accessToken) - } - return c.getCachedResponse(url, fetchFunc) - }() - if err != nil { - return nil, err + + fetchFunc := func() ([]Planet, error) { + return c.direct.GetCharacterPlanets(ctx, characterID, accessToken) } - return result.([]Planet), nil + + return getCachedResponse(c, url, fetchFunc) } // GetPlanetDetails retrieves detailed information about a specific planet with caching func (c *CachedESI) GetPlanetDetails(ctx context.Context, characterID, planetID int, accessToken string) (*PlanetDetail, error) { url := fmt.Sprintf("/v3/characters/%d/planets/%d/", characterID, planetID) - result, err := func() (interface{}, error) { - var fetchFunc func() (interface{}, error) = func() (interface{}, error) { - return c.direct.GetPlanetDetails(ctx, characterID, planetID, accessToken) - } - return c.getCachedResponse(url, fetchFunc) - }() - if err != nil { - return nil, err + fetchFunc := func() (*PlanetDetail, error) { + return c.direct.GetPlanetDetails(ctx, characterID, planetID, accessToken) } - return result.(*PlanetDetail), nil + + return getCachedResponse(c, url, fetchFunc) } -// getCachedResponse handles caching logic -func (c *CachedESI) getCachedResponse(url string, fetchFunc func() (interface{}, error)) (interface{}, error) { +// getCachedResponse handles caching logic with generics +func getCachedResponse[T any](c *CachedESI, url string, fetchFunc func() (T, error)) (T, error) { // Generate cache key hash := sha256.Sum256([]byte(url)) urlHash := hex.EncodeToString(hash[:]) @@ -75,22 +76,12 @@ func (c *CachedESI) getCachedResponse(url string, fetchFunc func() (interface{}, cacheEntry, err := c.db.GetCacheEntry(urlHash) if err == nil { // Check if cache is still valid - cacheValidity, _ := time.ParseDuration("10m") // Default 10 minutes - if time.Since(cacheEntry.CachedAt) < cacheValidity { + if time.Since(cacheEntry.CachedAt) < c.cacheValidity { logger.Debug("Cache hit for URL: %s", url) - // Parse cached response based on URL pattern - if url[len(url)-1:] == "/" { - // Planets endpoint - var planets []Planet - if err := json.Unmarshal([]byte(cacheEntry.Response), &planets); err == nil { - return planets, nil - } - } else { - // Planet details endpoint - var planetDetail PlanetDetail - if err := json.Unmarshal([]byte(cacheEntry.Response), &planetDetail); err == nil { - return &planetDetail, nil - } + // Parse cached response + var result T + if err := json.Unmarshal([]byte(cacheEntry.Response), &result); err == nil { + return result, nil } } } @@ -99,7 +90,8 @@ func (c *CachedESI) getCachedResponse(url string, fetchFunc func() (interface{}, logger.Debug("Cache miss for URL: %s", url) result, err := fetchFunc() if err != nil { - return nil, err + var zero T + return zero, err } // Store in cache diff --git a/esi/client.go b/esi/client.go index 1769c45..f30ddec 100644 --- a/esi/client.go +++ b/esi/client.go @@ -9,6 +9,8 @@ import ( "net/http" "time" + "go-eve-pi/options" + logger "git.site.quack-lab.dev/dave/cylogger" ) @@ -105,9 +107,16 @@ type DirectESI struct { // NewDirectESI creates a new DirectESI instance func NewDirectESI() *DirectESI { + // Parse HTTP timeout ONCE at initialization + httpTimeout, err := time.ParseDuration(options.GlobalOptions.HTTPTimeout) + if err != nil { + logger.Warning("Invalid HTTP timeout duration %s, using 30s default: %v", options.GlobalOptions.HTTPTimeout, err) + httpTimeout = 30 * time.Second + } + return &DirectESI{ httpClient: &http.Client{ - Timeout: 30 * time.Second, + Timeout: httpTimeout, }, } } @@ -127,8 +136,7 @@ func (d *DirectESI) GetCharacterPlanets(ctx context.Context, characterID int, ac req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + resp, err := d.httpClient.Do(req) if err != nil { logger.Error("Failed to fetch character planets: %v", err) return nil, err @@ -166,8 +174,7 @@ func (d *DirectESI) GetPlanetDetails(ctx context.Context, characterID, planetID req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + resp, err := d.httpClient.Do(req) if err != nil { logger.Error("Failed to fetch planet details: %v", err) return nil, err diff --git a/esi/sso.go b/esi/sso.go index b2d9df3..f516347 100644 --- a/esi/sso.go +++ b/esi/sso.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "go-eve-pi/options" "go-eve-pi/repositories" "go-eve-pi/types" @@ -279,7 +280,14 @@ func (s *SSO) processCallback(isGet bool, code, state string, writeResponse func } func (s *SSO) waitForCallback() (code, state string, err error) { - logger.Debug("Waiting for authentication callback (timeout: 30s)") + // Parse SSO callback timeout ONCE at initialization + callbackTimeout, err := time.ParseDuration(options.GlobalOptions.SSOCallbackTimeout) + if err != nil { + logger.Warning("Invalid SSO callback timeout duration %s, using 30s default: %v", options.GlobalOptions.SSOCallbackTimeout, err) + callbackTimeout = 30 * time.Second + } + + logger.Debug("Waiting for authentication callback (timeout: %v)", callbackTimeout) // Wait for callback through channel select { case result := <-s.callbackChan: @@ -289,8 +297,8 @@ func (s *SSO) waitForCallback() (code, state string, err error) { } logger.Debug("Callback received successfully") return result.code, result.state, result.err - case <-time.After(30 * time.Second): - logger.Error("Callback timeout after 30 seconds") + case <-time.After(callbackTimeout): + logger.Error("Callback timeout after %v", callbackTimeout) return "", "", errors.New("callback timeout") } } @@ -335,7 +343,7 @@ func (s *SSO) exchangeCodeForToken(ctx context.Context, code, verifier string) ( CharacterName: name, AccessToken: tr.AccessToken, RefreshToken: tr.RefreshToken, - ExpiresAt: time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second), + ExpiresAt: time.Now().Add(time.Duration(tr.ExpiresIn-options.GlobalOptions.TokenExpiryBuffer) * time.Second), }, nil } @@ -376,7 +384,7 @@ func (s *SSO) refreshToken(ctx context.Context, char *types.Character) error { char.RefreshToken = tr.RefreshToken } if tr.ExpiresIn > 0 { - char.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second) + char.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-options.GlobalOptions.TokenExpiryBuffer) * time.Second) } logger.Debug("Saving refreshed token to database for character %s", char.CharacterName) diff --git a/options/options.go b/options/options.go index abf40f7..ec1bd96 100644 --- a/options/options.go +++ b/options/options.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "reflect" + "strconv" "strings" logger "git.site.quack-lab.dev/dave/cylogger" @@ -29,7 +30,12 @@ type Options struct { ClientID string `env:"ESI_CLIENT_ID" description:"EVE SSO client ID"` RedirectURI string `env:"ESI_REDIRECT_URI" default:"http://localhost:3000/callback" description:"EVE SSO redirect URI"` Scopes []string `env:"ESI_SCOPES" default:"esi-planets.manage_planets.v1" description:"EVE SSO scopes (space-separated)"` - CacheValidity string `env:"ESI_CACHE_VALIDITY" default:"PT20M" description:"ESI cache validity in duration (e.g. PT20M for 20 minutes)"` + CacheValidity string `env:"ESI_CACHE_VALIDITY" default:"PT20M" description:"ESI cache validity in ISO8601 duration (e.g. PT20M for 20 minutes)"` + + // HTTP timeouts + HTTPTimeout string `env:"HTTP_TIMEOUT" default:"PT30S" description:"HTTP client timeout in ISO8601 duration (e.g. PT30S for 30 seconds)"` + SSOCallbackTimeout string `env:"SSO_CALLBACK_TIMEOUT" default:"PT30S" description:"SSO callback timeout in ISO8601 duration (e.g. PT30S for 30 seconds)"` + TokenExpiryBuffer int `env:"TOKEN_EXPIRY_BUFFER" default:"30" description:"Token expiry buffer in seconds"` WebhookURL string `env:"WEBHOOK_URL" description:"Webhook URL for notifications"` WebhookEmail string `env:"WEBHOOK_EMAIL" description:"Webhook authentication email"` @@ -80,6 +86,12 @@ func setFieldValue(field reflect.Value, value string, fieldType reflect.Type) er switch fieldType.Kind() { case reflect.String: field.SetString(value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + intValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid integer value: %w", err) + } + field.SetInt(intValue) case reflect.Slice: if fieldType.Elem().Kind() == reflect.String { // Handle []string by splitting on spaces diff --git a/webhook/zulip.go b/webhook/zulip.go index 8329813..ab769d8 100644 --- a/webhook/zulip.go +++ b/webhook/zulip.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "go-eve-pi/options" + logger "git.site.quack-lab.dev/dave/cylogger" ) @@ -23,12 +25,19 @@ type ZulipWebhook struct { func NewZulipWebhook(url, email, token string) *ZulipWebhook { logger.Info("Zulip webhook client initialized with email: %s", email) + // Parse HTTP timeout ONCE at initialization + httpTimeout, err := time.ParseDuration(options.GlobalOptions.HTTPTimeout) + if err != nil { + logger.Warning("Invalid HTTP timeout duration %s, using 30s default: %v", options.GlobalOptions.HTTPTimeout, err) + httpTimeout = 30 * time.Second + } + return &ZulipWebhook{ url: url, email: email, token: token, client: &http.Client{ - Timeout: 30 * time.Second, + Timeout: httpTimeout, }, } }