From 3836ee895de60d654da03be08a8efe341d6eb898 Mon Sep 17 00:00:00 2001
From: Ankit Chaurasia <8670962+sunank200@users.noreply.github.com>
Date: Sat, 2 Mar 2024 01:09:16 +0530
Subject: [PATCH] Add Backend Message and Frontend Banner for Service
Maintenance (#294)
### Description
- I am taking over this draft PR to implement display of service
maintenance banner selectively based on environment variables
- Added `health_status` endpoint on backend that reads from the env var
- Frontend displays maintenance banner if health status is `maintenance`
or if backend has no response coming back
### Tests
- When backend is down OR when backend has environment variable
`SERVICE_MAINTENANCE_BANNER=True`
- Other times (when env var is empty or if banner set to False)
- Slack
closes #302
---------
Co-authored-by: David Xue
---
api/ask_astro/models/request.py | 8 +++++-
api/ask_astro/rest/controllers/__init__.py | 2 ++
.../rest/controllers/health_status.py | 26 +++++++++++++++++++
api/ask_astro/services/questions.py | 12 ++++++++-
api/ask_astro/settings.py | 2 ++
api/ask_astro/slack/controllers/mention.py | 7 +++--
.../rest/controllers/test_health_status.py | 24 +++++++++++++++++
ui/src/routes/+layout.svelte | 3 ++-
ui/src/routes/+page.server.ts | 23 +++++++++++++---
ui/src/routes/+page.svelte | 16 ++++++++++++
10 files changed, 114 insertions(+), 9 deletions(-)
create mode 100644 api/ask_astro/rest/controllers/health_status.py
create mode 100644 tests/api/ask_astro/rest/controllers/test_health_status.py
diff --git a/api/ask_astro/models/request.py b/api/ask_astro/models/request.py
index 03c83b45..97f79b7c 100644
--- a/api/ask_astro/models/request.py
+++ b/api/ask_astro/models/request.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import datetime
-from typing import Any
+from typing import Any, Literal
from uuid import UUID
from langchain.schema import AIMessage, BaseMessage, HumanMessage
@@ -109,3 +109,9 @@ def from_dict(cls, response_dict: dict[str, Any]) -> AskAstroRequest:
is_example=response_dict.get("is_example", False),
client=response_dict["client"],
)
+
+
+class HealthStatus(BaseModel):
+ """Represents the health status of the service."""
+
+ status: Literal["ok", "maintenance"] = Field(..., description="The health status of the service")
diff --git a/api/ask_astro/rest/controllers/__init__.py b/api/ask_astro/rest/controllers/__init__.py
index b1df59dc..a8af4776 100644
--- a/api/ask_astro/rest/controllers/__init__.py
+++ b/api/ask_astro/rest/controllers/__init__.py
@@ -8,6 +8,7 @@
from sanic import Sanic, response
from ask_astro.rest.controllers.get_request import on_get_request
+from ask_astro.rest.controllers.health_status import on_get_health_status
from ask_astro.rest.controllers.list_recent_requests import on_list_recent_requests
from ask_astro.rest.controllers.post_request import on_post_request
from ask_astro.rest.controllers.submit_feedback import on_submit_feedback
@@ -31,6 +32,7 @@ def register_routes(api: Sanic):
RouteConfig(on_get_request, "/requests/", ["GET"], "get_request"),
RouteConfig(on_post_request, "/requests", ["POST"], "post_request"),
RouteConfig(on_submit_feedback, "/requests//feedback", ["POST"], "submit_feedback"),
+ RouteConfig(on_get_health_status, "/health_status", ["GET"], "health_status"),
]
for route_config in routes:
diff --git a/api/ask_astro/rest/controllers/health_status.py b/api/ask_astro/rest/controllers/health_status.py
new file mode 100644
index 00000000..f35a6efd
--- /dev/null
+++ b/api/ask_astro/rest/controllers/health_status.py
@@ -0,0 +1,26 @@
+"""
+Handles GET requests to the /ask/{question_id} endpoint.
+"""
+from __future__ import annotations
+
+from logging import getLogger
+
+from sanic import Request, json
+from sanic_ext import openapi
+
+from ask_astro import settings
+from ask_astro.models.request import HealthStatus
+
+logger = getLogger(__name__)
+
+
+@openapi.definition(response=HealthStatus.schema_json())
+async def on_get_health_status(request: Request) -> json:
+ """
+ Handles GET requests to the /health_status endpoint.
+
+ :param request: The Sanic request object.
+ """
+ if settings.SHOW_SERVICE_MAINTENANCE_BANNER:
+ return json({"status": "maintenance"}, status=200)
+ return json({"status": "healthy"}, status=200)
diff --git a/api/ask_astro/services/questions.py b/api/ask_astro/services/questions.py
index 20a6b90d..450f27b1 100644
--- a/api/ask_astro/services/questions.py
+++ b/api/ask_astro/services/questions.py
@@ -11,6 +11,7 @@
from ask_astro.clients.firestore import firestore_client
from ask_astro.config import FirestoreCollections, PromptPreprocessingConfig
from ask_astro.models.request import AskAstroRequest, Source
+from ask_astro.settings import SHOW_SERVICE_MAINTENANCE_BANNER
logger = getLogger(__name__)
@@ -19,6 +20,10 @@ class InvalidRequestPromptError(Exception):
"""Exception raised when the prompt string in the request object is invalid"""
+class RequestDuringMaintenanceException(Exception):
+ """Exception raised when a request is still somehow received on the backend when server is in maintenance"""
+
+
class QuestionAnsweringError(Exception):
"""Exception raised when an error occurs during question answering"""
@@ -37,6 +42,10 @@ async def _update_firestore_request(request: AskAstroRequest) -> None:
def _preprocess_request(request: AskAstroRequest) -> None:
+ if SHOW_SERVICE_MAINTENANCE_BANNER:
+ error_msg = "Ask Astro is currently undergoing maintenance and will be back shortly. We apologize for any inconvenience this may cause!"
+ request.response = error_msg
+ raise RequestDuringMaintenanceException(error_msg)
if len(request.prompt) > PromptPreprocessingConfig.max_char:
error_msg = "Question text is too long. Please try making a new thread and shortening your question."
request.response = error_msg
@@ -62,6 +71,7 @@ def _preprocess_request(request: AskAstroRequest) -> None:
retry=retry_if_not_exception_type(InvalidRequestPromptError),
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, max=10),
+ reraise=True,
)
async def answer_question(request: AskAstroRequest) -> None:
"""
@@ -110,7 +120,7 @@ async def answer_question(request: AskAstroRequest) -> None:
except Exception as e:
# If there's an error, mark the request as errored and add it to the database
request.status = "error"
- if not isinstance(e, InvalidRequestPromptError):
+ if not isinstance(e, (InvalidRequestPromptError, RequestDuringMaintenanceException)):
request.response = "Sorry, something went wrong. Please try again later."
raise QuestionAnsweringError("An error occurred during question answering.") from e
else:
diff --git a/api/ask_astro/settings.py b/api/ask_astro/settings.py
index 64ecafaa..d18d226c 100644
--- a/api/ask_astro/settings.py
+++ b/api/ask_astro/settings.py
@@ -20,3 +20,5 @@
CONVERSATIONAL_RETRIEVAL_LOAD_QA_CHAIN_DEPLOYMENT_NAME = os.environ.get(
"CONVERSATIONAL_RETRIEVAL_LOAD_QA_CHAIN_DEPLOYMENT_NAME", "gpt-4-32k"
)
+
+SHOW_SERVICE_MAINTENANCE_BANNER = os.environ.get("SHOW_SERVICE_MAINTENANCE_BANNER", "False").upper() == "TRUE"
diff --git a/api/ask_astro/slack/controllers/mention.py b/api/ask_astro/slack/controllers/mention.py
index 1faf1955..6d65bb50 100644
--- a/api/ask_astro/slack/controllers/mention.py
+++ b/api/ask_astro/slack/controllers/mention.py
@@ -12,7 +12,7 @@
from slack_sdk.web.async_client import AsyncWebClient
from ask_astro.models.request import AskAstroRequest
-from ask_astro.services.questions import answer_question
+from ask_astro.services.questions import InvalidRequestPromptError, RequestDuringMaintenanceException, answer_question
from ask_astro.slack.utils import get_blocks, markdown_to_slack
logger = getLogger(__name__)
@@ -121,6 +121,9 @@ async def on_mention(body: dict[str, Any], ack: AsyncAck, say: AsyncSay, client:
except Exception as e:
await client.reactions_remove(name=THOUGHT_BALLOON_REACTION, channel=channel_id, timestamp=ts)
await try_add_reaction(client, FAILURE_REACTION, channel_id, ts)
- await say(text=FAILURE_MESSAGE, thread_ts=ts)
+ if not isinstance(e, (InvalidRequestPromptError, RequestDuringMaintenanceException)):
+ await say(text=FAILURE_MESSAGE, thread_ts=ts)
+ else:
+ await say(text=str(request.response), thread_ts=ts)
raise e
diff --git a/tests/api/ask_astro/rest/controllers/test_health_status.py b/tests/api/ask_astro/rest/controllers/test_health_status.py
new file mode 100644
index 00000000..601e03f4
--- /dev/null
+++ b/tests/api/ask_astro/rest/controllers/test_health_status.py
@@ -0,0 +1,24 @@
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "show_maintenance_banner, expected_response",
+ [
+ (True, {"status": "maintenance"}),
+ (False, {"status": "healthy"}),
+ ],
+)
+async def test_health_status(app, show_maintenance_banner, expected_response):
+ """
+ Test the /health_status endpoint by mocking banner status environment variable
+ """
+
+ with patch("ask_astro.settings.SHOW_SERVICE_MAINTENANCE_BANNER", new=show_maintenance_banner):
+ _, response = await app.asgi_client.get("/health_status")
+
+ # Validating the response status code and content
+ assert response.status == 200
+ assert response.json == expected_response
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte
index 4fa79ea9..57b4045e 100644
--- a/ui/src/routes/+layout.svelte
+++ b/ui/src/routes/+layout.svelte
@@ -84,6 +84,7 @@
{/if}
+ {#if !$page.data.publicServiceAnnouncement}
+ {/if}
-