Skip to content

fix(optimiser): retry and parallelise module data fetches to fix intermittent timeouts#4459

Open
TheMythologist wants to merge 1 commit into
masterfrom
fix/optimiser-module-fetch-timeouts
Open

fix(optimiser): retry and parallelise module data fetches to fix intermittent timeouts#4459
TheMythologist wants to merge 1 commit into
masterfrom
fix/optimiser-module-fetch-timeouts

Conversation

@TheMythologist

Copy link
Copy Markdown
Contributor

Problem

Some optimiser requests were failing with errors like:

solve failed: Get "https://api.nusmods.com/v2/2026-2027/modules/PC5102.json": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

The optimiser fetched each module's timetable data from api.nusmods.com sequentially, with a single 10s client timeout and no retries. Any one slow fetch aborted the entire solve.

Root cause — the origin is healthy; the stall is in transit

Correlating the optimiser's error log (UTC) with the nginx origin access log (SGT, +0800). 14:59 UTC = 22:59 SGT:

Optimiser (UTC → SGT) nginx origin (SGT)
14:59:00.01622:59:00 "request received" 22:59:00 PC5101200 (785 B) first module, served instantly
22:59:13 PC5102200 (703 B) the "timed-out" fetch — served fine at origin
14:59:11.79522:59:11.8 "solve failed: PC5102 timeout" client gave up ~1.2s before origin logged PC5102

The optimiser dispatched PC5102 at ~22:59:01–02, its 10s timeout fired at 22:59:11.8, and the origin didn't serve PC5102 until 22:59:13 — ~11s after dispatch, and when it did, it was a trivially fast 200 for a 703-byte file. The origin never struggled; the request simply took ~11s to reach it.

Additional context from the log: 22:53–23:03 SGT shows a dense burst of Go-http-client/2.0 requests across dozens of modules (many concurrent optimiser invocations, AY26/27 registration season). This is a load-induced connection/transit stall on the optimiser → Cloudflare hop (likely cold Vercel instances doing fresh TLS handshakes from shared egress IPs during a burst).

Ruled out:

  • Rate-limiting — a Cloudflare/nginx limit returns a status code with headers, producing a different error; awaiting headers means no response headers arrived at all. No 429/503 anywhere in the origin log.
  • Origin slownessPC5102.json was served as a fast 200/304 hundreds of times that day, including seconds before and after the failure.

Changes

  • Retry transient failures (network errors, timeouts, 5xx) up to 3 attempts with a 300ms backoff. A 4xx (e.g. unknown module 404) is treated as permanent and not retried, so bad requests still fail fast.
  • Concurrent fetching — modules are fetched/processed in parallel instead of sequentially. The traffic is HTTP/2 (confirmed in the logs), so Go's transport multiplexes all fetches over a single connection; wall-clock is bounded by the slowest module rather than their sum, without adding connection-setup load.
  • Shorter per-attempt timeout — dropped from 10s to 4s. Since the failure mode is a connection/transit stall, failing fast and retrying on a fresh connection beats blocking the whole budget on one stalled attempt. Worst case ≈ 3×4s + 2×0.3s ≈ 12.6s, but a stall now recovers in ~4s instead of 10s.

Note

This fix makes the optimiser resilient to the transit stalls. It does not address the underlying infra cause (Vercel egress / Cloudflare connection reuse under burst) — worth a follow-up with Cloudflare logs if timeouts persist after deploy.

Module timetable data was fetched from api.nusmods.com one module at a
time with a single 10s client timeout and no retries, so one slow fetch
aborted the entire solve. Production logs showed a solve fail on
PC5102.json with "context deadline exceeded (while awaiting headers)".

nginx origin logs proved the origin was healthy: the same PC5102.json
was served as a fast 200 (703 bytes) roughly 11s after the optimiser
dispatched it, ~1s after the client had already timed out. The stall was
in the optimiser -> Cloudflare transit hop during a burst of concurrent
optimiser requests, not origin compute and not rate-limiting (a limit
would return a status code, with headers).

Changes:
- Retry transient failures (network/timeout/5xx) up to 3 attempts with a
  300ms backoff; 4xx (e.g. unknown module 404) is treated as permanent
  and not retried.
- Fetch and process modules concurrently instead of sequentially. Over
  HTTP/2 these multiplex over a single connection, so wall-clock is
  bounded by the slowest module rather than their sum.
- Lower the per-attempt timeout from 10s to 4s so a stalled connection is
  abandoned quickly and a fresh one is retried, rather than burning the
  whole budget on one attempt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@TheMythologist
TheMythologist requested a review from thejus03 July 9, 2026 21:41
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
nusmods-export Ignored Ignored Jul 9, 2026 9:41pm
nusmods-website Ignored Ignored Jul 9, 2026 9:41pm

Request Review

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes optimiser module data fetching more resilient. The main changes are:

  • Adds per-attempt timeout, retry count, and retry backoff constants.
  • Retries transient module fetch failures while keeping 4xx responses terminal.
  • Fetches and processes requested modules concurrently before assembling results.

Confidence Score: 5/5

The changed flow looks mergeable after bounding parallel fetch behavior.

  • Module result assembly avoids concurrent map writes.
  • Retry handling keeps permanent 4xx responses from being retried.
  • Large or partially invalid requests can still create avoidable outbound pressure.

website/api/optimiser/_modules/modules.go

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
website/api/optimiser/_modules/modules.go:54-67
**Unbounded Request Fanout**

When a request contains many modules, this loop starts one goroutine per module and each goroutine can make up to three outbound API attempts. A single oversized optimiser request can now create a burst of simultaneous HTTP calls, which can exhaust local resources or trigger upstream throttling instead of keeping outbound pressure bounded as the old sequential path did.

### Issue 2 of 2
website/api/optimiser/_modules/modules.go:68-72
**Terminal Errors Wait For All Fetches**

When one module has a permanent error such as a 404, the request still waits for every already-started module goroutine to finish before returning that error. Mixed valid and invalid requests now make unnecessary external calls and can wait on unrelated slow retries, whereas the old sequential path stopped at the first failing module.

Reviews (1): Last reviewed commit: "fix(optimiser): retry and parallelise mo..." | Re-trigger Greptile

Comment on lines +54 to +67
for i, module := range optimiserRequest.Modules {
wg.Add(1)
go func(i int, module string) {
defer wg.Done()
merged, defaults, err := processModule(
optimiserRequest,
module,
venues,
recordingsMap,
freeDaysMap,
)
results[i] = moduleResult{merged: merged, defaults: defaults, err: err}
}(i, module)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unbounded Request Fanout

When a request contains many modules, this loop starts one goroutine per module and each goroutine can make up to three outbound API attempts. A single oversized optimiser request can now create a burst of simultaneous HTTP calls, which can exhaust local resources or trigger upstream throttling instead of keeping outbound pressure bounded as the old sequential path did.

Prompt To Fix With AI
This is a comment left during a code review.
Path: website/api/optimiser/_modules/modules.go
Line: 54-67

Comment:
**Unbounded Request Fanout**

When a request contains many modules, this loop starts one goroutine per module and each goroutine can make up to three outbound API attempts. A single oversized optimiser request can now create a burst of simultaneous HTTP calls, which can exhaust local resources or trigger upstream throttling instead of keeping outbound pressure bounded as the old sequential path did.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TheMythologist I think this is a valid point also considering that we are working with not very powerful cpus in Vercel. Perhaps limiting it to some number of goroutines (ex: 4) using a Semaphore?

Comment on lines +68 to +72
wg.Wait()

var moduleData struct {
SemesterData []struct {
Semester int `json:"semester"`
Timetable []models.ModuleSlot `json:"timetable"`
} `json:"semesterData"`
}
err = json.Unmarshal(body, &moduleData)
if err != nil {
return nil, nil, nil, err
for i, module := range optimiserRequest.Modules {
if results[i].err != nil {
return nil, nil, nil, results[i].err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Terminal Errors Wait For All Fetches

When one module has a permanent error such as a 404, the request still waits for every already-started module goroutine to finish before returning that error. Mixed valid and invalid requests now make unnecessary external calls and can wait on unrelated slow retries, whereas the old sequential path stopped at the first failing module.

Prompt To Fix With AI
This is a comment left during a code review.
Path: website/api/optimiser/_modules/modules.go
Line: 68-72

Comment:
**Terminal Errors Wait For All Fetches**

When one module has a permanent error such as a 404, the request still waits for every already-started module goroutine to finish before returning that error. Mixed valid and invalid requests now make unnecessary external calls and can wait on unrelated slow retries, whereas the old sequential path stopped at the first failing module.

How can I resolve this? If you propose a fix, please make it concise.

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 58.48%. Comparing base (988c6fd) to head (f73247f).
⚠️ Report is 262 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4459      +/-   ##
==========================================
+ Coverage   54.52%   58.48%   +3.96%     
==========================================
  Files         274      316      +42     
  Lines        6076     7294    +1218     
  Branches     1455     1794     +339     
==========================================
+ Hits         3313     4266     +953     
- Misses       2763     3028     +265     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hacktron-app hacktron-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file

Severity Count
HIGH 1

View full scan results

Comment on lines +53 to +68
var wg sync.WaitGroup
for i, module := range optimiserRequest.Modules {
wg.Add(1)
go func(i int, module string) {
defer wg.Done()
merged, defaults, err := processModule(
optimiserRequest,
module,
venues,
recordingsMap,
freeDaysMap,
)
results[i] = moduleResult{merged: merged, defaults: defaults, err: err}
}(i, module)
}
wg.Wait()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH Unbounded Concurrency in Module Processing Leads to Denial of Service (DoS) via Resource Exhaustion

The pull request changes the module processing logic in GetAllModuleSlots from sequential execution to concurrent execution using Go goroutines. However, it does not enforce any limit on the number of concurrent goroutines spawned, nor does it validate or limit the size of the modules array in the incoming OptimiserRequest.

An unauthenticated attacker can submit a request with an arbitrarily large number of modules (e.g., thousands of duplicate or unique module codes). The backend will attempt to spawn a goroutine and initiate an outbound HTTP request to the NUSMods API for each module concurrently.

This unbounded concurrency can quickly exhaust system resources, specifically:

  1. File Descriptors: Each concurrent HTTP request opens a network socket. If the number of concurrent requests exceeds the operating system's file descriptor limit (typically 1024), the process will fail to open new sockets, causing errors for all concurrent users.
  2. Memory and CPU: Allocating goroutines, HTTP request/response buffers, and parsing JSON payloads concurrently for thousands of modules will exhaust the memory and CPU of the serverless function, causing Vercel to terminate the execution environment.
  3. IP Rate Limiting: The sudden burst of thousands of concurrent requests to api.nusmods.com can trigger rate-limiting or IP blocks, disrupting the service for legitimate users.
Steps to Reproduce

An attacker can send a POST request to the optimiser endpoint with a payload containing a large number of module codes:

{
  "modules": ["CS1010S", "CS1010S", ... (repeated 5000 times) ...],
  "recordings": [],
  "freeDays": [],
  "earliestTime": "0800",
  "latestTime": "1800",
  "acadYear": "2024-2025",
  "acadSem": 1,
  "maxConsecutiveHours": 4,
  "lunchStart": "1200",
  "lunchEnd": "1300"
}

This will trigger 5,000 concurrent goroutines and outbound HTTP requests, leading to socket exhaustion or serverless function termination.

Fix with AI

Open in Cursor Open in Claude

A security vulnerability was found by Hacktron.

File: website/api/optimiser/_modules/modules.go
Lines: 53-68
Severity: high

Vulnerability: Unbounded Concurrency in Module Processing Leads to Denial of Service (DoS) via Resource Exhaustion

Description:
The pull request changes the module processing logic in `GetAllModuleSlots` from sequential execution to concurrent execution using Go goroutines. However, it does not enforce any limit on the number of concurrent goroutines spawned, nor does it validate or limit the size of the `modules` array in the incoming `OptimiserRequest`.

An unauthenticated attacker can submit a request with an arbitrarily large number of modules (e.g., thousands of duplicate or unique module codes). The backend will attempt to spawn a goroutine and initiate an outbound HTTP request to the NUSMods API for each module concurrently.

This unbounded concurrency can quickly exhaust system resources, specifically:
1. **File Descriptors**: Each concurrent HTTP request opens a network socket. If the number of concurrent requests exceeds the operating system's file descriptor limit (typically 1024), the process will fail to open new sockets, causing errors for all concurrent users.
2. **Memory and CPU**: Allocating goroutines, HTTP request/response buffers, and parsing JSON payloads concurrently for thousands of modules will exhaust the memory and CPU of the serverless function, causing Vercel to terminate the execution environment.
3. **IP Rate Limiting**: The sudden burst of thousands of concurrent requests to `api.nusmods.com` can trigger rate-limiting or IP blocks, disrupting the service for legitimate users.

Proof of Concept:
An attacker can send a POST request to the optimiser endpoint with a payload containing a large number of module codes:

```json
{
  "modules": ["CS1010S", "CS1010S", ... (repeated 5000 times) ...],
  "recordings": [],
  "freeDays": [],
  "earliestTime": "0800",
  "latestTime": "1800",
  "acadYear": "2024-2025",
  "acadSem": 1,
  "maxConsecutiveHours": 4,
  "lunchStart": "1200",
  "lunchEnd": "1300"
}
```

This will trigger 5,000 concurrent goroutines and outbound HTTP requests, leading to socket exhaustion or serverless function termination.

Affected Code:
	var wg sync.WaitGroup
	for i, module := range optimiserRequest.Modules {
		wg.Add(1)
		go func(i int, module string) {
			defer wg.Done()
			merged, defaults, err := processModule(
				optimiserRequest,
				module,
				venues,
				recordingsMap,
				freeDaysMap,
			)
			results[i] = moduleResult{merged: merged, defaults: defaults, err: err}
		}(i, module)
	}
	wg.Wait()

Acceptance criteria:
- Acceptance is defined by the **actual reported behavior**, not by tests passing.
- Reproduce the issue, or narrow the exact code path that produces it, *before* changing code. State what you confirmed.
- Fix the underlying cause. Mitigations that paper over the reported behavior do not count as a fix.
- Add a regression test that fails on the unpatched code and passes on the fix. If a regression test is genuinely impractical (e.g. race condition, infra-level issue), say so and explain why.
- Existing tests passing is **not** the bar. Do not declare done on tests-pass theatre.

Only change what is necessary to fix this vulnerability. Do not refactor adjacent code or modify unrelated files.

Triage: Reply !fp <reason> (false positive), !valid (confirmed), !accepted_risk <reason>, or !fixed (resolved). Any other reply is saved as a triage note.
Reason is optional but improves future scans — e.g. !fp internal endpoint, not user-facing.

View finding in Hacktron

@thejus03 thejus03 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if I see the worth in making requests to api.nusmods.com concurrent because it adds unnecessary additional complexity and security issues when the query itself usually completes in milliseconds for all modules.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants