Skip to content

feat: implement HTTP caching headers (Cache-Control)#360

Merged
aaronbrethorst merged 2 commits intoOneBusAway:mainfrom
3rabiii:feat/add-http-caching
Feb 18, 2026
Merged

feat: implement HTTP caching headers (Cache-Control)#360
aaronbrethorst merged 2 commits intoOneBusAway:mainfrom
3rabiii:feat/add-http-caching

Conversation

@3rabiii
Copy link
Contributor

@3rabiii 3rabiii commented Feb 8, 2026

Summary

This PR implements standard HTTP Caching controls (Cache-Control headers) 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

  1. Middleware Implementation: Added CacheControlMiddleware to inject caching headers based on data type.
  2. Configuration: Defined standard cache durations in models/constants.go to ensure consistency:
    • Long Cache (5 mins): For static data (Stops, Routes, Agencies, Schedules).
    • Short Cache (30 secs): For real-time data (Vehicle positions, Arrivals).
    • No Cache: For user reporting endpoints.
  3. Routing Logic: Updated SetRoutes in routes.go to apply specific caching policies to every endpoint.
  4. Testing: Added caching_middleware_test.go to verify correct headers are set for different scenarios.

###Verification
Screenshot From 2026-02-09 00-31-51
Screenshot From 2026-02-09 00-47-02

@aaronbrethorst
closes : #359

@3rabiii 3rabiii force-pushed the feat/add-http-caching branch 2 times, most recently from c0d1c58 to b4246ce Compare February 13, 2026 13:15
@3rabiii 3rabiii force-pushed the feat/add-http-caching branch from 3e8e108 to 87f52c8 Compare February 13, 2026 13:39
Copy link
Member

@aaronbrethorst aaronbrethorst left a comment

Choose a reason for hiding this comment

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

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
@3rabiii
Copy link
Contributor Author

3rabiii commented Feb 17, 2026

@aaronbrethorst
Ready for review!

Copy link
Member

@aaronbrethorst aaronbrethorst left a comment

Choose a reason for hiding this comment

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

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.

@aaronbrethorst aaronbrethorst merged commit ab4d17a into OneBusAway:main Feb 18, 2026
4 checks passed
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.

feat: Implement HTTP Caching Strategies (Cache-Control)

2 participants