From cce43870927d8050cef0f8c8951af0f11db7544d Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 16 Mar 2026 12:53:39 +0900 Subject: [PATCH] Avoid search gateway timeouts --- TODO.md | 11 +++++++++++ backend/handlers/api.go | 10 +++++++--- backend/services/ranker.go | 13 +++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index c5cf455..dbb4e99 100644 --- a/TODO.md +++ b/TODO.md @@ -255,6 +255,17 @@ - backend debug broadcasts ## Recent Change Log +- Date: `2026-03-16` +- What changed: + - Added a search-time budget for Gemini evaluation so the API can return partial reviewed results before reverse-proxy timeout instead of surfacing `504 Gateway Time-out`. + - Supplemental exploration now only runs when there is enough remaining request budget. +- Why it changed: + - Fully sequential enrichment and Gemini evaluation improved coverage but made the endpoint vulnerable to upstream proxy timeouts. +- How it was verified: + - code-path inspection of deadline-aware Gemini evaluation and handler warning flow +- What is still risky or incomplete: + - Very slow upstream pages or repeated Gemini retries can still reduce final result count because the handler now prioritizes responding before proxy timeout. + - Date: `2026-03-16` - What changed: - Search enrichment now runs across the full result set sequentially instead of only enriching a capped top subset in parallel. diff --git a/backend/handlers/api.go b/backend/handlers/api.go index 53e86c7..3e00c34 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -270,6 +270,7 @@ func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath s func (a *App) searchMedia(c *gin.Context) { started := time.Now() + deadline := started.Add(22 * time.Second) var req struct { Query string `json:"query"` Platforms []string `json:"platforms"` @@ -321,16 +322,16 @@ func (a *App) searchMedia(c *gin.Context) { scored := services.RankSearchResults(rankQuery, results) a.debug("search ranked summary", summarizeSearchResults(scored, time.Since(started), services.GeminiCandidateLimit(len(scored)), "")) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75}) - recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored) + recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGeminiBudget(a.GeminiService, req.Query, scored, deadline) a.debug("search gemini evaluation", geminiStats) - if services.NeedsSupplementalExploration(recommended) { + if services.NeedsSupplementalExploration(recommended) && time.Now().Before(deadline.Add(-4*time.Second)) { a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini 평가가 약해 추가 후보를 탐색하는 중", "progress": 82}) explorationQueries := buildSupplementalQueries(req.Query, queryVariants) extraResults, extraErr := a.SearchService.SearchMedia(explorationQueries, enabledPlatforms) if extraErr == nil && len(extraResults) > 0 { results = mergeSearchResults(results, extraResults) scored = services.RankSearchResults(strings.Join(explorationQueries[:min(len(explorationQueries), 3)], " "), results) - recommended, geminiStats, geminiErr = services.EvaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored) + recommended, geminiStats, geminiErr = services.EvaluateAllCandidatesWithGeminiBudget(a.GeminiService, req.Query, scored, deadline) a.debug("search supplemental query variants", gin.H{"variants": explorationQueries, "variantCount": len(explorationQueries)}) a.debug("search gemini evaluation after supplemental search", geminiStats) } @@ -361,6 +362,9 @@ func (a *App) searchMedia(c *gin.Context) { if geminiErr != nil { warning = geminiErr.Error() } + if geminiStats.TimedOut && warning == "" { + warning = "search returned partial Gemini-reviewed results before the gateway timeout budget" + } a.debug("search complete summary", summarizeRecommendationResults(merged, time.Since(started), warning)) response := gin.H{"results": merged, "queries": queryVariants} if warning != "" { diff --git a/backend/services/ranker.go b/backend/services/ranker.go index 00f32f6..f87a5a9 100644 --- a/backend/services/ranker.go +++ b/backend/services/ranker.go @@ -18,6 +18,7 @@ type GeminiBatchStats struct { Failed int `json:"failed"` SequentialRetried int `json:"sequentialRetried"` RecommendedCount int `json:"recommendedCount"` + TimedOut bool `json:"timedOut,omitempty"` Errors []string `json:"errors,omitempty"` } @@ -90,6 +91,10 @@ func GeminiCandidateLimit(total int) int { } func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranked []SearchResult) ([]AIRecommendation, GeminiBatchStats, error) { + return EvaluateAllCandidatesWithGeminiBudget(service, query, ranked, time.Time{}) +} + +func EvaluateAllCandidatesWithGeminiBudget(service *GeminiService, query string, ranked []SearchResult, deadline time.Time) ([]AIRecommendation, GeminiBatchStats, error) { if service == nil { return nil, GeminiBatchStats{}, fmt.Errorf("gemini service is not configured") } @@ -107,6 +112,10 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke merged := make([]AIRecommendation, 0, len(ranked)) seen := map[string]bool{} for idx := 0; idx < limit; idx++ { + if !deadline.IsZero() && time.Now().After(deadline) { + stats.TimedOut = true + break + } recommendations, err := recoverGeminiCandidateSequentially(service, query, ranked[idx]) if err != nil { stats.Failed++ @@ -131,6 +140,10 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke stats.RecommendedCount = len(merged) switch { + case stats.TimedOut && len(merged) > 0: + return merged, stats, fmt.Errorf("gemini vision evaluation stopped early to avoid request timeout") + case stats.TimedOut && len(merged) == 0: + return nil, stats, fmt.Errorf("gemini vision evaluation timed out before any usable results were returned") case len(merged) > 0 && stats.Failed == 0: return merged, stats, nil case len(merged) > 0 && stats.Failed > 0: