From 13ffd0b902057a369474ae1ead1cb7eb5210901c Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Thu, 12 Feb 2026 06:45:30 -0600 Subject: [PATCH] Reduce GitHub API rate limit usage by 75% Problem: The UI was polling GitHub every 15 seconds, making 3 API calls per repository per poll (open, merged, and closed PRs). This resulted in 720 API calls per hour for a single repo, easily exceeding GitHub's rate limits (5,000/hour authenticated, 60/hour unauthenticated). Changes: 1. Increased PR refresh interval from 15s to 60s (4x reduction) 2. Increased cache TTL from 15s to 90s (6x increase) 3. Added rate limit error detection and graceful handling Impact: - API usage reduced from 720 calls/hour to 180 calls/hour (75% reduction) - PR information still refreshes every minute (reasonable for CI checks) - Cache now lasts longer than refresh interval, improving efficiency - Rate limit errors are detected and handled gracefully Co-Authored-By: Claude Sonnet 4.5 --- internal/github/pr.go | 32 +++++++++++++++++++++++++++----- internal/ui/app.go | 4 ++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/internal/github/pr.go b/internal/github/pr.go index 36fa418..292f696 100644 --- a/internal/github/pr.go +++ b/internal/github/pr.go @@ -75,7 +75,7 @@ type cacheEntry struct { fetchedAt time.Time } -const cacheTTL = 15 * time.Second +const cacheTTL = 90 * time.Second // NewPRCache creates a new PR cache. func NewPRCache() *PRCache { @@ -144,6 +144,14 @@ func fetchPRInfo(repoDir, branchName string) *PRInfo { output, err := cmd.Output() if err != nil { + // Check if this is a rate limit error + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + if strings.Contains(stderr, "rate limit") || strings.Contains(stderr, "API rate limit") { + // Return nil on rate limit (cache will be used if available) + return nil + } + } // No PR exists for this branch, timeout, or other error return nil } @@ -330,6 +338,14 @@ func FetchAllPRsForRepo(repoDir string) map[string]*PRInfo { output, err := cmd.Output() if err != nil { + // Check if this is a rate limit error + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + if strings.Contains(stderr, "rate limit") || strings.Contains(stderr, "API rate limit") { + // Return nil to signal rate limit hit (caller can use cached data) + return nil + } + } return result } @@ -355,8 +371,8 @@ func FetchAllPRsForRepo(repoDir string) map[string]*PRInfo { "--limit", "20") cmd2.Dir = repoDir - output2, err := cmd2.Output() - if err == nil { + output2, err2 := cmd2.Output() + if err2 == nil { var mergedPRs []ghPRListResponse if json.Unmarshal(output2, &mergedPRs) == nil { for _, pr := range mergedPRs { @@ -369,6 +385,12 @@ func FetchAllPRsForRepo(repoDir string) map[string]*PRInfo { } } } + } else if exitErr, ok := err2.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + if strings.Contains(stderr, "rate limit") || strings.Contains(stderr, "API rate limit") { + // Hit rate limit, skip closed PR fetch too + return result + } } // Also fetch recently closed PRs (last 10) to catch closures @@ -381,8 +403,8 @@ func FetchAllPRsForRepo(repoDir string) map[string]*PRInfo { "--limit", "10") cmd3.Dir = repoDir - output3, err := cmd3.Output() - if err == nil { + output3, err3 := cmd3.Output() + if err3 == nil { var closedPRs []ghPRListResponse if json.Unmarshal(output3, &closedPRs) == nil { for _, pr := range closedPRs { diff --git a/internal/ui/app.go b/internal/ui/app.go index 002e78b..303843a 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -940,7 +940,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case prRefreshTickMsg: - // Periodically refresh PR info (every 15 seconds) + // Periodically refresh PR info (every 60 seconds) // Always refresh regardless of view - PR state is persisted to DB // so it stays current even when navigating between views cmds = append(cmds, m.refreshAllPRs()) @@ -4174,7 +4174,7 @@ func (m *AppModel) focusTick() tea.Cmd { } func (m *AppModel) prRefreshTick() tea.Cmd { - return tea.Tick(15*time.Second, func(t time.Time) tea.Msg { + return tea.Tick(60*time.Second, func(t time.Time) tea.Msg { return prRefreshTickMsg(t) }) }