fix: resolve jikan api eof and context cancellation errors

- add http transport with connection pooling to prevent stale connections
- check ctx.Done() before each retry attempt to abort early
- pass context to renderer to skip writing on canceled requests
This commit is contained in:
2026-05-02 23:27:25 +02:00
parent 723152f370
commit 56f0951d5e
6 changed files with 48 additions and 18 deletions

View File

@@ -29,7 +29,7 @@ type quickSearchResult struct {
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) { func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,
}); err != nil { }); err != nil {
log.Printf("render error: %v", err) log.Printf("render error: %v", err)
@@ -98,7 +98,7 @@ func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := templates.GetRenderer().ExecuteTemplate(w, "index.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "index.gohtml", map[string]any{
"MostPopular": animes.Animes, "MostPopular": animes.Animes,
"CurrentlyAiring": currentlyAiring.Animes, "CurrentlyAiring": currentlyAiring.Animes,
"ContinueWatching": cw, "ContinueWatching": cw,
@@ -150,7 +150,7 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := templates.GetRenderer().ExecuteFragment(w, "browse.gohtml", "anime_card_scroll", map[string]any{ err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "browse.gohtml", "anime_card_scroll", map[string]any{
"Animes": res.Animes, "Animes": res.Animes,
"NextPage": page + 1, "NextPage": page + 1,
"HasNextPage": res.HasNextPage, "HasNextPage": res.HasNextPage,
@@ -184,7 +184,7 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := templates.GetRenderer().ExecuteTemplate(w, "browse.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "browse.gohtml", map[string]any{
"User": user, "User": user,
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,
"Query": q, "Query": q,
@@ -251,7 +251,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := templates.GetRenderer().ExecuteTemplate(w, "anime.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "anime.gohtml", map[string]any{
"Anime": anime, "Anime": anime,
"User": user, "User": user,
"Status": status, "Status": status,
@@ -287,7 +287,7 @@ func (h *Handler) HandleHTMLWatchOrder(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := templates.GetRenderer().ExecuteFragment(w, "anime.gohtml", "watch_order", map[string]any{ if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "anime.gohtml", "watch_order", map[string]any{
"Relations": relations, "Relations": relations,
"AnimeID": id, "AnimeID": id,
"WatchlistMap": watchlistMap, "WatchlistMap": watchlistMap,
@@ -390,7 +390,7 @@ func (h *Handler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := templates.GetRenderer().ExecuteTemplate(w, "discover.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "discover.gohtml", map[string]any{
"User": user, "User": user,
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,
"Trending": uniqueTrending, "Trending": uniqueTrending,

View File

@@ -25,7 +25,7 @@ func rateLimitErrorFromQuery(r *http.Request) string {
} }
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
if err := templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,
}); err != nil { }); err != nil {
log.Printf("render error: %v", err) log.Printf("render error: %v", err)
@@ -34,7 +34,7 @@ func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{ templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"Error": "Something went wrong. Please try again.", "Error": "Something went wrong. Please try again.",
"Username": "", "Username": "",
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,
@@ -46,7 +46,7 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password") password := r.FormValue("password")
if username == "" || password == "" { if username == "" || password == "" {
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{ templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"Error": "The email or password is wrong.", "Error": "The email or password is wrong.",
"Username": username, "Username": username,
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,
@@ -56,7 +56,7 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
session, err := h.authService.Login(r.Context(), username, password) session, err := h.authService.Login(r.Context(), username, password)
if err != nil { if err != nil {
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{ templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"Error": "The email or password is wrong.", "Error": "The email or password is wrong.",
"Username": username, "Username": username,
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,

View File

@@ -30,7 +30,7 @@ func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) { func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,
}); err != nil { }); err != nil {
log.Printf("render error: %v", err) log.Printf("render error: %v", err)
@@ -174,7 +174,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := templates.GetRenderer().ExecuteTemplate(w, "watch.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "watch.gohtml", map[string]any{
"Anime": anime, "Anime": anime,
"Episodes": episodes.Data, "Episodes": episodes.Data,
"WatchData": watchData, "WatchData": watchData,

View File

@@ -115,7 +115,7 @@ func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
entries, err := h.service.GetUserWatchlist(r.Context(), user.ID) entries, err := h.service.GetUserWatchlist(r.Context(), user.ID)
if err != nil { if err != nil {
log.Printf("failed to fetch watchlist: %v", err) log.Printf("failed to fetch watchlist: %v", err)
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{ if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path, "CurrentPath": r.URL.Path,
}); err != nil { }); err != nil {
log.Printf("render error: %v", err) log.Printf("render error: %v", err)
@@ -158,7 +158,7 @@ func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
templateName = "watchlist_partial.gohtml" templateName = "watchlist_partial.gohtml"
} }
if err := templates.GetRenderer().ExecuteTemplate(w, templateName, data); err != nil { if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, templateName, data); err != nil {
log.Printf("render error: %v", err) log.Printf("render error: %v", err)
} }
} }

View File

@@ -27,7 +27,15 @@ type Client struct {
func NewClient(db database.Querier) *Client { func NewClient(db database.Querier) *Client {
return &Client{ return &Client{
httpClient: &http.Client{Timeout: 10 * time.Second}, httpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableKeepAlives: false,
TLSHandshakeTimeout: 5 * time.Second,
},
},
baseURL: "https://api.jikan.moe/v4", baseURL: "https://api.jikan.moe/v4",
db: db, db: db,
retrySignal: make(chan struct{}, 1), retrySignal: make(chan struct{}, 1),
@@ -284,6 +292,12 @@ func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Dur
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error { func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
maxRetries := 5 maxRetries := 5
for attempt := range maxRetries { for attempt := range maxRetries {
select {
case <-ctx.Done():
return fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
default:
}
if err := c.waitRateLimit(ctx); err != nil { if err := c.waitRateLimit(ctx); err != nil {
return err return err
} }
@@ -295,6 +309,9 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
return fmt.Errorf("request canceled while retrying jikan request: %w", err)
}
if attempt < maxRetries-1 && IsRetryableError(err) { if attempt < maxRetries-1 && IsRetryableError(err) {
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil { if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return retryErr return retryErr

View File

@@ -1,6 +1,7 @@
package templates package templates
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
@@ -114,7 +115,13 @@ func GetRenderer() *Renderer {
return renderer return renderer
} }
func (r *Renderer) ExecuteTemplate(wr io.Writer, name string, data any) error { func (r *Renderer) ExecuteTemplate(ctx context.Context, wr io.Writer, name string, data any) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
tmpl, ok := r.templates[name] tmpl, ok := r.templates[name]
if !ok { if !ok {
return fmt.Errorf("template %s not found", name) return fmt.Errorf("template %s not found", name)
@@ -122,7 +129,13 @@ func (r *Renderer) ExecuteTemplate(wr io.Writer, name string, data any) error {
return tmpl.ExecuteTemplate(wr, "base.gohtml", data) return tmpl.ExecuteTemplate(wr, "base.gohtml", data)
} }
func (r *Renderer) ExecuteFragment(wr io.Writer, name string, block string, data any) error { func (r *Renderer) ExecuteFragment(ctx context.Context, wr io.Writer, name string, block string, data any) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
tmpl, ok := r.templates[name] tmpl, ok := r.templates[name]
if !ok { if !ok {
return fmt.Errorf("template %s not found", name) return fmt.Errorf("template %s not found", name)