diff --git a/api b/api index cc3ba92..5fc545e 100755 Binary files a/api and b/api differ diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index a520f42..b70de9b 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -4,6 +4,7 @@ import ( "crypto/subtle" "encoding/json" "fmt" + "log" "net/http" "os" "strings" @@ -52,6 +53,7 @@ type routeSearchRoute struct { Transfers int `json:"transfers"` Cost int `json:"cost"` ID string `json:"id"` + SearchID string `json:"search_id"` 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() 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 routeResponses := make([]routeSearchRoute, 0, len(results)) for _, route := range results { @@ -279,6 +284,7 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) { Transfers: route.TotalTransfers, Cost: route.Cost, ID: route.ID, + SearchID: searchID, 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 { expectedAPIKey := os.Getenv("TRIP_PLANNER_ADMIN_API_KEY") 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 } 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) 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 } @@ -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 { - 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 } @@ -629,7 +638,8 @@ func RemoveSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request) cityCode := parts[4] 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 } @@ -652,7 +662,8 @@ func GetSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request history, err := hc.Preferences.GetSearchHistory(r.Context(), userID) 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 } @@ -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 { - 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 } diff --git a/internal/routing/graph.go b/internal/routing/graph.go index 4ff5c17..fcb37d2 100644 --- a/internal/routing/graph.go +++ b/internal/routing/graph.go @@ -2,13 +2,24 @@ package routing import ( "context" + "fmt" "log" "sort" + "sync/atomic" "time" "trip-planner/internal/storage" "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. type Edge struct { From *Node @@ -376,7 +387,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta transfers: 0, duration: 0, lastArrival: "", - itinerary: &Itinerary{Legs: []RouteLeg{}}, + itinerary: &Itinerary{Legs: []RouteLeg{}, ID: generateItineraryID()}, } // 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, TotalTransfers: newTransfers, Cost: current.itinerary.Cost + edge.Cost, + ID: generateItineraryID(), } // 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"), } - 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 API call fails, log the error and return nil (no route found) log.Printf("WARNING: yandex search failed for route expansion: %v", err) diff --git a/internal/yandex/client.go b/internal/yandex/client.go index 8710964..56cae56 100644 --- a/internal/yandex/client.go +++ b/internal/yandex/client.go @@ -155,11 +155,14 @@ func (c *Client) Do(ctx context.Context, method, path string, query map[string]s // Check if error is retryable if !isRetryableError(err) { - c.circuitBreaker.recordFailure() + transitionedToOpen := c.circuitBreaker.recordFailure() + if transitionedToOpen && c.metrics != nil { + c.metrics.RecordCircuitBreakerTrip() + } return nil, err } - c.circuitBreaker.recordFailure() + transitionedToOpen := c.circuitBreaker.recordFailure() if attempt < c.retryConfig.maxRetries { 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) } 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 } @@ -396,23 +401,28 @@ func (cb *circuitBreaker) recordSuccess() { } } -func (cb *circuitBreaker) recordFailure() { +func (cb *circuitBreaker) recordFailure() bool { cb.mu.Lock() defer cb.mu.Unlock() + transitionedToOpen := false switch cb.state { case closed: cb.failures++ if cb.failures >= cb.failThreshold { cb.state = open cb.openSince = time.Now() + transitionedToOpen = true } case halfOpen: cb.state = open cb.openSince = time.Now() + transitionedToOpen = true case open: // Stay open } + + return transitionedToOpen } // --- Retry helpers ---