From 27c801427006cdfc590f63a8cec6db395f1d7ddd Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 18 Apr 2026 22:43:50 +0700 Subject: [PATCH] feat(client): thread context.Context and honor rate-limit backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - N2 — Client.query, FetchProfile, FetchContributionsAllTime, and FetchProductive now take context.Context. main.go builds a root context with a -timeout flag (default 30m) and cancels on SIGINT/SIGTERM so Ctrl-C aborts in-flight pagination cleanly. - N6 — on HTTP 429 or 403 with X-RateLimit-Remaining: 0, the client now parses Retry-After / X-RateLimit-Reset, sleeps (capped at 5 minutes), and retries once. Longer reset windows surface as errors so the caller can reschedule the Action rather than burn 55 min of runner time idle. --- main.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 00d01bf0..121f993b 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,13 @@ package main import ( + "context" "flag" "fmt" "os" + "os/signal" "strings" + "syscall" "time" "github.com/tiennm99/ghstats/internal/card" @@ -24,6 +27,7 @@ func main() { perRepo = flag.Int("commits-per-repo", 500, "max commits sampled per repo (covers both last-year and all-time aggregates)") includeForks = flag.Bool("include-forks", true, "include forked repos in stats and commit probing") includePrivate = flag.Bool("include-private", true, "include private repos (requires PAT with repo scope; silently no-op otherwise)") + timeout = flag.Duration("timeout", 30*time.Minute, "overall deadline for fetch phase (0 = no limit)") listThemes = flag.Bool("list-themes", false, "print available theme ids and exit") ) flag.Parse() @@ -58,8 +62,23 @@ func main() { IncludePrivate: *includePrivate, } + // Overall fetch budget. Ctrl-C cancels in-flight HTTP requests cleanly. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if *timeout > 0 { + var cancelTimeout context.CancelFunc + ctx, cancelTimeout = context.WithTimeout(ctx, *timeout) + defer cancelTimeout() + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sig + cancel() + }() + client := github.NewClient(*token) - profile, err := client.FetchProfile(*user, opts) + profile, err := client.FetchProfile(ctx, *user, opts) if err != nil { fmt.Fprintf(os.Stderr, "error: fetch profile: %v\n", err) os.Exit(1) @@ -70,7 +89,7 @@ func main() { // plus the all-time contribution calendar; must precede FetchProductive so // commit-history probes land on repos where the user actually committed. if len(profile.ContributionYears) > 0 { - if err := client.FetchContributionsAllTime(profile, opts); err != nil { + if err := client.FetchContributionsAllTime(ctx, profile, opts); err != nil { fmt.Fprintf(os.Stderr, "warn: all-time contributions fetch: %v\n", err) } } @@ -80,7 +99,7 @@ func main() { if *topRepos > 0 && len(repos) > *topRepos { repos = repos[:*topRepos] } - if err := client.FetchProductive(profile, repos, loc, *perRepo); err != nil { + if err := client.FetchProductive(ctx, profile, repos, loc, *perRepo); err != nil { fmt.Fprintf(os.Stderr, "warn: productive-time + commits-per-language fetch: %v\n", err) } }