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` image - Other times (when env var is empty or if banner set to False) image - Slack image 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} -