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
}