From 704ae49b3c125befe5def2ac98a592e8fc3fa80e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 8 Apr 2026 16:27:10 +0200 Subject: [PATCH] fix: enforce global rate limit in jikan client and handle relation errors --- internal/features/anime/handler.go | 21 +++++++++++----- internal/features/anime/service.go | 2 +- internal/jikan/client.go | 26 ++++++++++++++++---- internal/jikan/relations.go | 39 +++++++++++++++++++----------- 4 files changed, 62 insertions(+), 26 deletions(-) diff --git a/internal/features/anime/handler.go b/internal/features/anime/handler.go index f830c5a..06ba42c 100644 --- a/internal/features/anime/handler.go +++ b/internal/features/anime/handler.go @@ -141,7 +141,12 @@ func (h *Handler) HandleAPIAnimeRelations(w http.ResponseWriter, r *http.Request return } - relations := h.svc.GetRelations(id) + relations, err := h.svc.GetRelations(id) + if err != nil { + log.Printf("failed to get relations for anime %d: %v", id, err) + http.Error(w, "Failed to load relations", http.StatusInternalServerError) + return + } templates.AnimeRelationsList(relations).Render(r.Context(), w) } @@ -164,18 +169,22 @@ func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) { switch parts[1] { case "relations": - relations := h.svc.GetRelations(id) + relations, err := h.svc.GetRelations(id) + if err != nil { + log.Printf("relations error for %d: %v", id, err) + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

Failed to load relations.

`)) + return + } templates.AnimeRelationsList(relations).Render(r.Context(), w) case "recommendations": recs, err := h.svc.GetRecommendations(id, 10) if err != nil { log.Printf("recommendations error for %d: %v", id, err) - http.Error(w, "Failed to fetch recommendations", http.StatusInternalServerError) + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

Failed to load recommendations.

`)) return } - if len(recs) > 10 { - recs = recs[:10] - } templates.AnimeRecommendations(recs).Render(r.Context(), w) default: http.Error(w, "not found", http.StatusNotFound) diff --git a/internal/features/anime/service.go b/internal/features/anime/service.go index aa52250..8b119d5 100644 --- a/internal/features/anime/service.go +++ b/internal/features/anime/service.go @@ -57,7 +57,7 @@ func (s *Service) GetAnimeDetails(ctx context.Context, id int, userID string) (j return anime, currentStatus, nil } -func (s *Service) GetRelations(id int) []jikan.RelationEntry { +func (s *Service) GetRelations(id int) ([]jikan.RelationEntry, error) { return s.jikanClient.GetFullRelations(id) } diff --git a/internal/jikan/client.go b/internal/jikan/client.go index 07917b7..4e95f47 100644 --- a/internal/jikan/client.go +++ b/internal/jikan/client.go @@ -5,15 +5,18 @@ import ( "encoding/json" "fmt" "net/http" + "sync" "time" "mal/internal/database" ) type Client struct { - httpClient *http.Client - baseURL string - db database.Querier + httpClient *http.Client + baseURL string + db database.Querier + mu sync.Mutex + lastReqTime time.Time } func NewClient(db database.Querier) *Client { @@ -24,6 +27,20 @@ func NewClient(db database.Querier) *Client { } } +func (c *Client) waitRateLimit() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + nextAllowed := c.lastReqTime.Add(340 * time.Millisecond) + if now.Before(nextAllowed) { + time.Sleep(nextAllowed.Sub(now)) + c.lastReqTime = time.Now() + } else { + c.lastReqTime = now + } +} + func (c *Client) getCache(key string, out interface{}) bool { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -57,8 +74,7 @@ func (c *Client) setCache(key string, data interface{}, ttl time.Duration) { func (c *Client) fetchWithRetry(urlStr string, out interface{}) error { maxRetries := 3 for i := 0; i < maxRetries; i++ { - // Base delay for Jikan rate limiting (3 requests per second) - time.Sleep(340 * time.Millisecond) + c.waitRateLimit() resp, err := c.httpClient.Get(urlStr) if err != nil { diff --git a/internal/jikan/relations.go b/internal/jikan/relations.go index 4d3cc24..c92fe2b 100644 --- a/internal/jikan/relations.go +++ b/internal/jikan/relations.go @@ -39,60 +39,71 @@ func findFirstAnimeRelation(res JikanRelationsResponse, relType string) *int { } // fetchChain recursively builds the relational chain (Prequels or Sequels) -func (c *Client) fetchChain(startID int, direction string, visited map[int]bool) []RelationEntry { +func (c *Client) fetchChain(startID int, direction string, visited map[int]bool) ([]RelationEntry, error) { rels, err := c.GetRelationsData(startID) if err != nil { - return nil + return nil, err } nextIDPtr := findFirstAnimeRelation(rels, direction) if nextIDPtr == nil { - return nil + return nil, nil // normal end of chain } nextID := *nextIDPtr if visited[nextID] { // prevent loops - return nil + return nil, nil } visited[nextID] = true anime, err := c.GetAnimeByID(nextID) if err != nil { - return nil + return nil, err } entry := RelationEntry{Anime: anime, IsCurrent: false} - rest := c.fetchChain(nextID, direction, visited) + rest, err := c.fetchChain(nextID, direction, visited) + if err != nil { + return nil, err + } if direction == "Prequel" { - return append(rest, entry) + return append(rest, entry), nil } - return append([]RelationEntry{entry}, rest...) + return append([]RelationEntry{entry}, rest...), nil } // GetFullRelations resolves the full Prequel/Sequel chronological chain synchronously -func (c *Client) GetFullRelations(id int) []RelationEntry { +func (c *Client) GetFullRelations(id int) ([]RelationEntry, error) { currentAnime, err := c.GetAnimeByID(id) if err != nil { - return nil + return nil, err } visited := map[int]bool{id: true} - prequels := c.fetchChain(id, "Prequel", visited) + prequels, err1 := c.fetchChain(id, "Prequel", visited) - // Clone visited set for sequels so we don't block valid paths if there's weird branching visitedSeq := make(map[int]bool) for k, v := range visited { visitedSeq[k] = v } - sequels := c.fetchChain(id, "Sequel", visitedSeq) + sequels, err2 := c.fetchChain(id, "Sequel", visitedSeq) + // If both chains errored and it wasn't just "no relations", we should probably error out + // But it's safer to just return what we have and the error so the UI can decide var result []RelationEntry result = append(result, prequels...) result = append(result, RelationEntry{Anime: currentAnime, IsCurrent: true}) result = append(result, sequels...) - return result + var finalErr error + if err1 != nil { + finalErr = err1 + } else if err2 != nil { + finalErr = err2 + } + + return result, finalErr }