fix: address code review findings

- Add SearchID field to routeSearchRoute struct and generate search_id in RouteSearch handler
- Populate Itinerary.ID field with unique IDs using generateItineraryID function
- Fix information disclosure in error handling - log errors internally instead of leaking to clients
- Fix circuit breaker trip metric recording - only record when state transitions to open
- Fix context not passed in route expansion - use context.WithTimeout instead of context.TODO
- Fix admin auth fails open on missing API key - return 401 Unauthorized without revealing configuration
This commit is contained in:
2026-08-18 20:51:09 +03:00
parent b0dcc2dd3a
commit 1513a9fb67
4 changed files with 50 additions and 12 deletions

BIN
api

Binary file not shown.

View File

@@ -4,6 +4,7 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@@ -52,6 +53,7 @@ type routeSearchRoute struct {
Transfers int `json:"transfers"` Transfers int `json:"transfers"`
Cost int `json:"cost"` Cost int `json:"cost"`
ID string `json:"id"` ID string `json:"id"`
SearchID string `json:"search_id"`
PriceNote string `json:"price_note,omitempty"` // "цена не указана" if price data not available from API PriceNote string `json:"price_note,omitempty"` // "цена не указана" if price data not available from API
} }
@@ -270,6 +272,9 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
duration := time.Since(start).Nanoseconds() duration := time.Since(start).Nanoseconds()
hc.Metrics.RecordSearch(duration) hc.Metrics.RecordSearch(duration)
// Generate a search_id based on the request parameters
searchID := fmt.Sprintf("search_%s_%s_%s_%d", req.FromCityID, req.ToCityID, req.Date, time.Now().Unix())
// Build response routes // Build response routes
routeResponses := make([]routeSearchRoute, 0, len(results)) routeResponses := make([]routeSearchRoute, 0, len(results))
for _, route := range results { for _, route := range results {
@@ -279,6 +284,7 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
Transfers: route.TotalTransfers, Transfers: route.TotalTransfers,
Cost: route.Cost, Cost: route.Cost,
ID: route.ID, ID: route.ID,
SearchID: searchID,
PriceNote: priceNote, PriceNote: priceNote,
}) })
} }
@@ -467,7 +473,8 @@ func StationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
func adminAuth(hc *HandlerContext, w http.ResponseWriter, r *http.Request) bool { func adminAuth(hc *HandlerContext, w http.ResponseWriter, r *http.Request) bool {
expectedAPIKey := os.Getenv("TRIP_PLANNER_ADMIN_API_KEY") expectedAPIKey := os.Getenv("TRIP_PLANNER_ADMIN_API_KEY")
if expectedAPIKey == "" { if expectedAPIKey == "" {
http.Error(w, "server configuration error: TRIP_PLANNER_ADMIN_API_KEY is not set", http.StatusInternalServerError) // Admin auth not configured - reject all admin requests
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false return false
} }
providedAPIKey := r.Header.Get("X-Admin-Api-Key") providedAPIKey := r.Header.Get("X-Admin-Api-Key")
@@ -567,7 +574,8 @@ func GetSavedCities(hc *HandlerContext, w http.ResponseWriter, r *http.Request)
cities, err := hc.Preferences.GetSavedCities(r.Context(), userID) cities, err := hc.Preferences.GetSavedCities(r.Context(), userID)
if err != nil { if err != nil {
http.Error(w, "failed to get saved cities: "+err.Error(), http.StatusInternalServerError) log.Printf("error getting saved cities: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -599,7 +607,8 @@ func AddSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
} }
if err := hc.Preferences.AddSavedCity(r.Context(), userID, req.CityCode, req.Name); err != nil { if err := hc.Preferences.AddSavedCity(r.Context(), userID, req.CityCode, req.Name); err != nil {
http.Error(w, "failed to add saved city: "+err.Error(), http.StatusInternalServerError) log.Printf("error adding saved city: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -629,7 +638,8 @@ func RemoveSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request)
cityCode := parts[4] cityCode := parts[4]
if err := hc.Preferences.RemoveSavedCity(r.Context(), userID, cityCode); err != nil { if err := hc.Preferences.RemoveSavedCity(r.Context(), userID, cityCode); err != nil {
http.Error(w, "failed to remove saved city: "+err.Error(), http.StatusInternalServerError) log.Printf("error removing saved city: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -652,7 +662,8 @@ func GetSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request
history, err := hc.Preferences.GetSearchHistory(r.Context(), userID) history, err := hc.Preferences.GetSearchHistory(r.Context(), userID)
if err != nil { if err != nil {
http.Error(w, "failed to get search history: "+err.Error(), http.StatusInternalServerError) log.Printf("error getting search history: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@@ -691,7 +702,8 @@ func AddSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request
} }
if err := hc.Preferences.AddSearchHistory(r.Context(), userID, req.FromCity, req.ToCity, req.Date); err != nil { if err := hc.Preferences.AddSearchHistory(r.Context(), userID, req.FromCity, req.ToCity, req.Date); err != nil {
http.Error(w, "failed to add search history: "+err.Error(), http.StatusInternalServerError) log.Printf("error adding search history: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }

View File

@@ -2,13 +2,24 @@ package routing
import ( import (
"context" "context"
"fmt"
"log" "log"
"sort" "sort"
"sync/atomic"
"time" "time"
"trip-planner/internal/storage" "trip-planner/internal/storage"
"trip-planner/internal/yandex" "trip-planner/internal/yandex"
) )
// itineraryIDCounter is a counter for generating unique itinerary IDs.
var itineraryIDCounter uint64
// generateItineraryID generates a unique ID for an itinerary.
func generateItineraryID() string {
id := atomic.AddUint64(&itineraryIDCounter, 1)
return fmt.Sprintf("route_%016x", id)
}
// Edge represents a graph edge connecting two nodes. // Edge represents a graph edge connecting two nodes.
type Edge struct { type Edge struct {
From *Node From *Node
@@ -376,7 +387,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
transfers: 0, transfers: 0,
duration: 0, duration: 0,
lastArrival: "", lastArrival: "",
itinerary: &Itinerary{Legs: []RouteLeg{}}, itinerary: &Itinerary{Legs: []RouteLeg{}, ID: generateItineraryID()},
} }
// Use a simple slice as priority queue - sort by (duration, transfers) // Use a simple slice as priority queue - sort by (duration, transfers)
@@ -462,6 +473,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
TotalDuration: newDurationWithMCT, TotalDuration: newDurationWithMCT,
TotalTransfers: newTransfers, TotalTransfers: newTransfers,
Cost: current.itinerary.Cost + edge.Cost, Cost: current.itinerary.Cost + edge.Cost,
ID: generateItineraryID(),
} }
// Skip this edge if it would exceed the maximum allowed transfers // Skip this edge if it would exceed the maximum allowed transfers
@@ -627,7 +639,11 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
"date": time.Now().Format("2006-01-02"), "date": time.Now().Format("2006-01-02"),
} }
resp, err := yandexClient.Do(context.TODO(), "GET", "/v3.0/search/", query) // Create a context with timeout for the Yandex API call
searchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := yandexClient.Do(searchCtx, "GET", "/v3.0/search/", query)
if err != nil { if err != nil {
// If API call fails, log the error and return nil (no route found) // If API call fails, log the error and return nil (no route found)
log.Printf("WARNING: yandex search failed for route expansion: %v", err) log.Printf("WARNING: yandex search failed for route expansion: %v", err)

View File

@@ -155,11 +155,14 @@ func (c *Client) Do(ctx context.Context, method, path string, query map[string]s
// Check if error is retryable // Check if error is retryable
if !isRetryableError(err) { if !isRetryableError(err) {
c.circuitBreaker.recordFailure() transitionedToOpen := c.circuitBreaker.recordFailure()
if transitionedToOpen && c.metrics != nil {
c.metrics.RecordCircuitBreakerTrip()
}
return nil, err return nil, err
} }
c.circuitBreaker.recordFailure() transitionedToOpen := c.circuitBreaker.recordFailure()
if attempt < c.retryConfig.maxRetries { if attempt < c.retryConfig.maxRetries {
backoff := c.retryConfig.baseBackoff backoff := c.retryConfig.baseBackoff
@@ -167,10 +170,12 @@ func (c *Client) Do(ctx context.Context, method, path string, query map[string]s
backoff = applyJitter(backoff) backoff = applyJitter(backoff)
} }
time.Sleep(backoff) time.Sleep(backoff)
} else if transitionedToOpen && c.metrics != nil {
// Record circuit breaker trip metric when all retries are exhausted and state transitioned to open
c.metrics.RecordCircuitBreakerTrip()
} }
} }
c.metrics.RecordCircuitBreakerTrip()
return nil, err return nil, err
} }
@@ -396,23 +401,28 @@ func (cb *circuitBreaker) recordSuccess() {
} }
} }
func (cb *circuitBreaker) recordFailure() { func (cb *circuitBreaker) recordFailure() bool {
cb.mu.Lock() cb.mu.Lock()
defer cb.mu.Unlock() defer cb.mu.Unlock()
transitionedToOpen := false
switch cb.state { switch cb.state {
case closed: case closed:
cb.failures++ cb.failures++
if cb.failures >= cb.failThreshold { if cb.failures >= cb.failThreshold {
cb.state = open cb.state = open
cb.openSince = time.Now() cb.openSince = time.Now()
transitionedToOpen = true
} }
case halfOpen: case halfOpen:
cb.state = open cb.state = open
cb.openSince = time.Now() cb.openSince = time.Now()
transitionedToOpen = true
case open: case open:
// Stay open // Stay open
} }
return transitionedToOpen
} }
// --- Retry helpers --- // --- Retry helpers ---