diff --git a/requirements.txt b/requirements.txt index 09f90d3..ca8a010 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ s3fs uvicorn xarray>=2022.06 zarr +fastapi-cache2 @ git+https://github.com/andersy005/fastapi-cache@pydantic-v2-compat # for pydantic v2 compatilibity diff --git a/src/app.py b/src/app.py index 9b460c0..78cbe30 100644 --- a/src/app.py +++ b/src/app.py @@ -1,13 +1,57 @@ from __future__ import annotations +import os +import traceback +from contextlib import asynccontextmanager + import pydantic from fastapi import FastAPI, Query, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend +from fastapi_cache.decorator import cache + +from .log import get_logger origins = ['*'] -app = FastAPI() +logger = get_logger() + + +@asynccontextmanager +async def lifespan_event(app: FastAPI): + """ + Context manager that yields the application's startup and shutdown events. + """ + logger.info('โฑ๏ธ Application startup...') + + worker_num = int(os.environ.get('APP_WORKER_ID', 9999)) + + logger.info(f'๐Ÿ‘ท Worker num: {worker_num}') + + # set up cache + logger.info('๐Ÿ”ฅ Setting up cache...') + expiration = int(60 * 60 * 1) # 24 hours + cache_status_header = 'x-html-reprs-cache' + FastAPICache.init( + InMemoryBackend(), + expire=expiration, + cache_status_header=cache_status_header, + ) + logger.info( + f'๐Ÿ”ฅ Cache set up with expiration={expiration:,} seconds | {cache_status_header} cache status header.' + ) + + yield + + logger.info('Application shutdown...') + logger.info('Clearing cache...') + FastAPICache.reset() + logger.info('๐Ÿ‘‹ Goodbye!') + + +app = FastAPI(lifespan=lifespan_event) app.add_middleware( CORSMiddleware, allow_origins=origins, @@ -23,6 +67,7 @@ def index(): @app.get('/xarray/') +@cache(namespace='html-reprs') def xarray( url: pydantic.AnyUrl = Query( ..., @@ -30,27 +75,52 @@ def xarray( example='https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/HadISST-feedstock/hadisst.zarr', ), ): + logger.info(f'๐Ÿš€ Starting to process request for URL: {url}') + + import time + import xarray as xr import zarr - error_message = f'An error occurred while fetching the data from URL: {url}' + start_time = time.time() + + error_message = f'โŒ An error occurred while fetching the data from URL: {url}' try: + logger.info(f'๐Ÿ“‚ Attempting to open dataset from URL: {url}') with xr.open_dataset(url.unicode_string(), engine='zarr', chunks={}) as ds: + logger.info('โœ… Successfully opened dataset. Generating HTML representation.') html = ds._repr_html_().strip() del ds + logger.info('๐Ÿงน Cleaned up dataset object') + + end_time = time.time() + processing_time = end_time - start_time + logger.info(f'๐Ÿ Request processed successfully in {processing_time:.2f} seconds') return {'html': html, 'dataset': url} except (zarr.errors.GroupNotFoundError, FileNotFoundError): + message = traceback.format_exc() + logger.error(f'๐Ÿ” {error_message}: Dataset not found\n{message}') return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, - content={'detail': f'{error_message}. Dataset not found.'}, + content={'detail': f'{error_message} Dataset not found. ๐Ÿ˜•'}, ) except PermissionError: + message = traceback.format_exc() + logger.error(f'๐Ÿ”’ {error_message}: Permission denied\n{message}') return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, - content={'detail': f'{error_message}. Permission denied.'}, + content={'detail': f'{error_message} Permission denied. ๐Ÿšซ'}, + ) + + except Exception as e: + message = traceback.format_exc() + logger.error(f'๐Ÿ’ฅ {error_message}: Unexpected error\n{message}') + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={'detail': f'{error_message} {str(e)} ๐Ÿ˜ฑ'}, ) diff --git a/src/log.py b/src/log.py new file mode 100644 index 0000000..9177b81 --- /dev/null +++ b/src/log.py @@ -0,0 +1,21 @@ +import logging +import os +import sys + + +def get_logger() -> logging.Logger: + logger = logging.getLogger('html-reprs') + worker_id = os.environ.get('APP_WORKER_ID', '') + + if not logger.handlers: + handler = logging.StreamHandler(stream=sys.stdout) + if worker_id != '': + handler.setFormatter( + logging.Formatter(f'[%(name)s] [worker {worker_id}] [%(levelname)s] %(message)s') + ) + else: + handler.setFormatter(logging.Formatter('[%(name)s] [%(levelname)s] %(message)s')) + logger.addHandler(handler) + + logger.setLevel(logging.DEBUG) + return logger