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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
Reference in New Issue
Block a user