diff --git a/internal/webui/client/backend_client.go b/internal/webui/client/backend_client.go index eeeee41..7b3e6a7 100644 --- a/internal/webui/client/backend_client.go +++ b/internal/webui/client/backend_client.go @@ -11,6 +11,9 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + alertpb "notificator/internal/backend/proto/alert" authpb "notificator/internal/backend/proto/auth" "notificator/internal/webui/models" @@ -71,6 +74,25 @@ func (c *BackendClient) IsConnected() bool { return c.conn != nil && c.authClient != nil && c.statisticsClient != nil } +// IsUnavailableError reports whether err comes from being unable to reach the +// backend (transport down, timed out dialing) rather than the backend +// rejecting the request (e.g. an invalid or expired session). +func IsUnavailableError(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + if !ok { + return false + } + switch st.Code() { + case codes.Unavailable, codes.DeadlineExceeded: + return true + default: + return false + } +} + func (c *BackendClient) HealthCheck() error { if !c.IsConnected() { return fmt.Errorf("not connected to backend") diff --git a/internal/webui/client/backend_client_test.go b/internal/webui/client/backend_client_test.go index 38280af..1339272 100644 --- a/internal/webui/client/backend_client_test.go +++ b/internal/webui/client/backend_client_test.go @@ -2,11 +2,14 @@ package client import ( "context" + "errors" "testing" alertpb "notificator/internal/backend/proto/alert" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // fakeAlertClient embeds the real interface (nil) so it satisfies @@ -92,3 +95,25 @@ func TestDeleteComment_Success(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestIsUnavailableError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"unavailable", status.Error(codes.Unavailable, "backend down"), true}, + {"deadline exceeded", status.Error(codes.DeadlineExceeded, "timeout"), true}, + {"invalid session (plain error)", errors.New("invalid session"), false}, + {"unauthenticated grpc status", status.Error(codes.Unauthenticated, "invalid session"), false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsUnavailableError(tc.err); got != tc.want { + t.Errorf("IsUnavailableError(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/internal/webui/middleware/auth.go b/internal/webui/middleware/auth.go index 7d5fc58..fa7889b 100644 --- a/internal/webui/middleware/auth.go +++ b/internal/webui/middleware/auth.go @@ -37,6 +37,12 @@ func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc { // Validate session with backend user, err := am.backendClient.ValidateSession(sessionID) if err != nil { + if client.IsUnavailableError(err) { + // Backend is unreachable, not a bad session - keep the session intact + c.JSON(http.StatusServiceUnavailable, models.ErrorResponse("Authentication service unavailable")) + c.Abort() + return + } // Session is invalid, clear it ClearSession(c) c.JSON(http.StatusUnauthorized, models.ErrorResponse("Invalid or expired session")) @@ -66,6 +72,8 @@ func (am *AuthMiddleware) OptionalAuth() gin.HandlerFunc { if err == nil && user != nil { c.Set("user", user) c.Set("session_id", sessionID) + } else if err != nil && client.IsUnavailableError(err) { + // Backend is unreachable, not a bad session - keep the session intact } else { // Clear invalid session ClearSession(c) @@ -93,6 +101,12 @@ func (am *AuthMiddleware) RedirectIfNotAuth(redirectTo string) gin.HandlerFunc { // Validate session with backend user, err := am.backendClient.ValidateSession(sessionID) if err != nil { + if client.IsUnavailableError(err) { + // Backend is unreachable, not a bad session - keep the session intact + c.String(http.StatusServiceUnavailable, "Service temporarily unavailable") + c.Abort() + return + } // Session is invalid, clear it and redirect ClearSession(c) c.Redirect(http.StatusFound, redirectTo) @@ -124,6 +138,10 @@ func (am *AuthMiddleware) RedirectIfAuth(redirectTo string) gin.HandlerFunc { c.Redirect(http.StatusFound, redirectTo) c.Abort() return + } else if client.IsUnavailableError(err) { + // Backend is unreachable, not a bad session - keep the session intact + c.Next() + return } else { // Clear invalid session ClearSession(c) diff --git a/internal/webui/templates/scripts/dashboard_core.templ b/internal/webui/templates/scripts/dashboard_core.templ index 9f25e7f..6836538 100644 --- a/internal/webui/templates/scripts/dashboard_core.templ +++ b/internal/webui/templates/scripts/dashboard_core.templ @@ -258,8 +258,9 @@ templ DashboardCore() { // Check if response indicates authentication failure handleAuthError(response) { - // Redirect to login if unauthorized or service unavailable - if (response.status === 401 || response.status === 503) { + // Redirect to login only on real auth failure; 503 is a transient + // infra state (cache warm-up, backend restart), not an auth signal. + if (response.status === 401) { window.location.href = '/login'; return true; } diff --git a/internal/webui/templates/scripts/dashboard_core_templ.go b/internal/webui/templates/scripts/dashboard_core_templ.go index 1ffa376..c65aa40 100644 --- a/internal/webui/templates/scripts/dashboard_core_templ.go +++ b/internal/webui/templates/scripts/dashboard_core_templ.go @@ -29,7 +29,7 @@ func DashboardCore() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }