feat: implement HTTP caching headers (Cache-Control)#360
feat: implement HTTP caching headers (Cache-Control)#360aaronbrethorst merged 2 commits intoOneBusAway:mainfrom
Conversation
c0d1c58 to
b4246ce
Compare
3e8e108 to
87f52c8
Compare
aaronbrethorst
left a comment
There was a problem hiding this comment.
Hey Adel, thanks for tackling HTTP caching — this is a meaningful performance improvement, and the three-tier classification (long/short/none) with clean constant definitions is a thoughtful approach. The route grouping in SetRoutes by cache tier also makes the policy easy to scan at a glance. Before we can merge this, I will need you to make a couple changes:
Critical
1. make lint fails with an errcheck violation
File: internal/restapi/caching_middleware_test.go:45
Per our commit requirements, make lint must pass before merging. Fix:
defer func() { _ = resp.Body.Close() }()2. Cache-Control headers are applied to error responses
File: internal/restapi/caching_middleware.go:10-18
The middleware sets the Cache-Control header before calling next.ServeHTTP, which means every response — including 403 (invalid API key), 404 (not found), 429 (rate limited), and 500 (server error) — gets the same cache header as a successful response.
This is a correctness problem. If a client or CDN respects Cache-Control: public, max-age=300 on a 404 for /api/where/stop/nonexistent.json, that 404 gets cached for 5 minutes. After a GTFS hot-swap that adds the stop, clients continue receiving the stale 404. Similarly, a 429 rate-limit response cached for 5 minutes at a CDN would block all users from that endpoint.
The fix is to intercept WriteHeader so you can inspect the status code before setting the cache header. Here's a complete approach:
func CacheControlMiddleware(durationSeconds int, next http.Handler) http.Handler {
var headerValue string
if durationSeconds > 0 {
headerValue = fmt.Sprintf("public, max-age=%d", durationSeconds)
} else {
headerValue = "no-cache, no-store, must-revalidate"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wrapped := &cacheControlWriter{
ResponseWriter: w,
headerValue: headerValue,
}
next.ServeHTTP(wrapped, r)
})
}
type cacheControlWriter struct {
http.ResponseWriter
headerValue string
headerWritten bool
}
func (w *cacheControlWriter) WriteHeader(code int) {
if !w.headerWritten {
w.headerWritten = true
if code >= 200 && code < 300 {
w.ResponseWriter.Header().Set("Cache-Control", w.headerValue)
} else {
w.ResponseWriter.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
}
w.ResponseWriter.WriteHeader(code)
}
func (w *cacheControlWriter) Write(b []byte) (int, error) {
if !w.headerWritten {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}Note this also precomputes the header string once in the closure rather than calling fmt.Sprintf on every request (see Fit and Finish #5 below).
Important
3. trip/{id} should be long cache, not short
File: internal/restapi/routes.go:75
The trip/{id} handler (trip_handler.go) queries only static GTFS data — the scheduled trip, its route, and its agency. It contains no real-time data. This is distinct from trip-details/{id}, which does include real-time trip status and correctly uses short cache.
Move trip/{id} to the long-cache group:
mux.Handle("GET /api/where/trip/{id}", CacheControlMiddleware(models.CacheDurationLong, rateLimitAndValidateAPIKey(api, api.tripHandler)))4. Add test coverage for error response caching
Once Critical #2 is fixed, add a test case verifying that error responses (e.g., a 404 for a nonexistent stop) receive no-cache, no-store, must-revalidate instead of the endpoint's default cache policy. For example:
{
name: "Error Response (No Cache)",
endpoint: "/api/where/stop/nonexistent_id.json?key=TEST",
expectedHeader: "no-cache, no-store, must-revalidate",
},Fit and Finish
5. Precompute the header value
File: internal/restapi/caching_middleware.go:12
durationSeconds is constant per middleware instance, but fmt.Sprintf("public, max-age=%d", durationSeconds) runs on every request. Compute the string once in the constructor and capture it in the closure. This is addressed in the Critical #2 code snippet above — the headerValue variable is computed once outside the handler function.
6. Unrelated fix in test_helper_test.go
File: internal/restapi/test_helper_test.go:72, 130
The running.Add(1) moved before the goroutine launch is a correct race condition fix, but it's unrelated to HTTP caching. Ideally this would be a separate commit — please keep it but note it in your commit message (e.g., "also fix race in test_helper_test.go").
Thanks again, and I look forward to merging this change.
- Refactor middleware to intercept WriteHeader (fixes error caching bug).
- Precompute Cache-Control header strings for better performance.
- Update route policies (trip/{id} is now long-cached).
- Fix lint error in middleware tests (body close).
- Test fix: resolve race condition in test_helper_test.go by moving Add(1).solveing conflict
|
@aaronbrethorst |
aaronbrethorst
left a comment
There was a problem hiding this comment.
Hey Adel, excellent work addressing every item from the first review. The cacheControlWriter implementation is clean and correct — intercepting WriteHeader to conditionally set cache headers is exactly the right approach, and precomputing the header value in the closure is a nice touch. The tier classifications in routes.go are well-organized and easy to scan. The error response test case rounds out the coverage nicely.
There's nothing left to change — this PR is ready to merge. Solid work turning this around so thoroughly.
Summary
This PR implements standard HTTP Caching controls (
Cache-Controlheaders) for the API endpoints. This is a performance optimization to reduce redundant server load and bandwidth usage by instructing clients to cache static data locally.Key Changes
CacheControlMiddlewareto inject caching headers based on data type.models/constants.goto ensure consistency:SetRoutesinroutes.goto apply specific caching policies to every endpoint.caching_middleware_test.goto verify correct headers are set for different scenarios.###Verification


@aaronbrethorst
closes : #359