📌 Description
internal/handlers/user_profile.go's ProjectsContributed handler fetches a paginated list of projects a user has contributed to, then loops over the result rows and calls out to GitHub once per row to fetch the owner's avatar:
gh := github.NewClient()
var projects []fiber.Map
for rows.Next() {
var id uuid.UUID
var fullName, status string
...
if err := rows.Scan(&id, &fullName, &status, &ecosystemName, &language, &ownerUserID); err != nil {
slog.Error("failed to scan project row", "error", err)
continue
}
// Fetch owner avatar from GitHub (higher-res with ?s=128)
var ownerAvatarURL *string
repo, repoErr := gh.GetRepo(c.Context(), accessToken, fullName)
if repoErr == nil && !repo.Private && repo.Owner.AvatarURL != "" {
...
}
projects = append(projects, fiber.Map{...})
}
With pagination allowing up to 100 rows per page (ParsePagination(c, 10, 100)), a single request to this endpoint can trigger up to 100 sequential, synchronous outbound HTTP calls to GitHub's API (gh.GetRepo), each one blocking the request-handling goroutine until it completes (or times out). This is a classic N+1 pattern applied to an external API rather than a database — it multiplies both the endpoint's latency (sum of N round-trips instead of one, or a handful, batched/parallelized) and this backend's consumption of the calling user's (or the app's) GitHub API rate limit budget for what is, per row, just an avatar URL.
🧩 Requirements and context
- The per-row
gh.GetRepo call for the owner avatar must not serialize N external API calls on the critical path of a single HTTP request in a way that scales linearly with page size.
- A reasonable fix can either (a) fetch avatars for all rows in the current page concurrently (bounded goroutines, e.g. via an
errgroup or a small worker pool, since GitHub API calls are I/O-bound and independent per row) rather than sequentially, or (b) avoid the external call entirely by deriving the avatar URL from already-known/cached data (this codebase already builds a GitHub avatar URL directly from a login elsewhere: ghAvatarURL := fmt.Sprintf("https://github.com/%s.png?size=200", *githubLogin) in PublicProfile — check whether p.owner_user_id's associated GitHub login is already available/joinable from the github_accounts table so this whole external call can be replaced by that same URL-construction pattern instead of a live API fetch).
- Preserve the existing response shape (
owner_avatar_url field) and behavior when a repo is private (no avatar shown) or the GitHub call fails (graceful omission, not an error).
🛠️ Suggested execution
- Prefer option (b) first: check if
p.owner_user_id can be joined against github_accounts/users in the existing SQL query to pull the owner's GitHub login and avatar URL directly (a single additional LEFT JOIN), eliminating the need for gh.GetRepo in this handler entirely for the common case, falling back to the current per-row API call only when no linked account/avatar is available.
- If a live per-row lookup is still needed for repos without a linked owner, replace the sequential loop with a bounded-concurrency fan-out (e.g.
golang.org/x/sync/errgroup with SetLimit(N), check go.mod for whether this dependency already exists) so the N calls happen in parallel instead of one after another, capped to avoid overwhelming GitHub's rate limiter.
- Add a test/benchmark in
internal/handlers/user_profile_test.go demonstrating the fixed version's request latency doesn't scale linearly with the number of returned rows (e.g. using a fake GitHub client with an artificial per-call delay, asserting total handler execution time stays roughly constant rather than growing with row count).
✅ Acceptance criteria
🔒 Security notes
No new attack surface; this is a performance/reliability fix. Unbounded sequential external calls per request also represent a resource-exhaustion risk under load (many concurrent requests to this endpoint each holding open up to 100 sequential outbound connections/goroutines), and unnecessarily burns this backend's shared GitHub API rate limit budget on data (avatar URLs) that's often derivable without an API call at all.
📋 Guidelines
- Minimum 95% test coverage
- Clear documentation
- Timeframe: 96 hours
📌 Description
internal/handlers/user_profile.go'sProjectsContributedhandler fetches a paginated list of projects a user has contributed to, then loops over the result rows and calls out to GitHub once per row to fetch the owner's avatar:With pagination allowing up to 100 rows per page (
ParsePagination(c, 10, 100)), a single request to this endpoint can trigger up to 100 sequential, synchronous outbound HTTP calls to GitHub's API (gh.GetRepo), each one blocking the request-handling goroutine until it completes (or times out). This is a classic N+1 pattern applied to an external API rather than a database — it multiplies both the endpoint's latency (sum of N round-trips instead of one, or a handful, batched/parallelized) and this backend's consumption of the calling user's (or the app's) GitHub API rate limit budget for what is, per row, just an avatar URL.🧩 Requirements and context
gh.GetRepocall for the owner avatar must not serialize N external API calls on the critical path of a single HTTP request in a way that scales linearly with page size.errgroupor a small worker pool, since GitHub API calls are I/O-bound and independent per row) rather than sequentially, or (b) avoid the external call entirely by deriving the avatar URL from already-known/cached data (this codebase already builds a GitHub avatar URL directly from a login elsewhere:ghAvatarURL := fmt.Sprintf("https://github.com/%s.png?size=200", *githubLogin)inPublicProfile— check whetherp.owner_user_id's associated GitHub login is already available/joinable from thegithub_accountstable so this whole external call can be replaced by that same URL-construction pattern instead of a live API fetch).owner_avatar_urlfield) and behavior when a repo is private (no avatar shown) or the GitHub call fails (graceful omission, not an error).🛠️ Suggested execution
p.owner_user_idcan be joined againstgithub_accounts/usersin the existing SQL query to pull the owner's GitHub login and avatar URL directly (a single additionalLEFT JOIN), eliminating the need forgh.GetRepoin this handler entirely for the common case, falling back to the current per-row API call only when no linked account/avatar is available.golang.org/x/sync/errgroupwithSetLimit(N), checkgo.modfor whether this dependency already exists) so the N calls happen in parallel instead of one after another, capped to avoid overwhelming GitHub's rate limiter.internal/handlers/user_profile_test.godemonstrating the fixed version's request latency doesn't scale linearly with the number of returned rows (e.g. using a fake GitHub client with an artificial per-call delay, asserting total handler execution time stays roughly constant rather than growing with row count).✅ Acceptance criteria
ProjectsContributed's response latency no longer scales linearly with the number of rows in the requested page due to sequential external API calls.🔒 Security notes
No new attack surface; this is a performance/reliability fix. Unbounded sequential external calls per request also represent a resource-exhaustion risk under load (many concurrent requests to this endpoint each holding open up to 100 sequential outbound connections/goroutines), and unnecessarily burns this backend's shared GitHub API rate limit budget on data (avatar URLs) that's often derivable without an API call at all.
📋 Guidelines