Skip to content

Commit c36c691

Browse files
allissonCopilot
andauthored
feat: improve the logs (#29)
* feat: add performance timing and logging to auth, clients, and subscriptions services - Measure execution duration for token validation, message cleanup, and subscription creation - Log debug info on successful validations, warnings on failures, and errors on exceptions - Include relevant metadata like client_id, scopes, duration, and error details in logs - Enhances observability for debugging and performance monitoring * Update fastpubsub/services/topics.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update fastpubsub/services/subscriptions.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update fastpubsub/services/clients.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update fastpubsub/services/clients.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(api): add generic exception handler Add a generic exception handler in the FastAPI app to catch unhandled exceptions and return a 500 Internal Server Error with a generic message, preventing leakage of sensitive application internals. Also, update test fixture to downgrade migrations to 'base' instead of '-1' for cleaner teardown. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent c0df552 commit c36c691

9 files changed

Lines changed: 706 additions & 148 deletions

File tree

fastpubsub/api/app.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""FastAPI application setup and configuration."""
22

33
from fastapi import FastAPI, Request, status
4-
from fastapi.responses import ORJSONResponse
4+
from fastapi.responses import JSONResponse, ORJSONResponse
55
from prometheus_fastapi_instrumentator import Instrumentator
66

77
from fastpubsub import models
@@ -132,6 +132,25 @@ def invalid_client_token_exception_handler(request: Request, exc: InvalidClientT
132132
"""
133133
return _create_error_response(models.GenericError, status.HTTP_403_FORBIDDEN, exc)
134134

135+
@app.exception_handler(Exception)
136+
def generic_exception_handler(request: Request, exc: Exception):
137+
"""Handle generic Exception instances.
138+
139+
Catches any unhandled exceptions that don't have specific handlers.
140+
Returns a generic 500 Internal Server Error response to avoid leaking
141+
sensitive information about the application internals.
142+
143+
Args:
144+
request: The incoming HTTP request that caused the exception.
145+
exc: The unhandled exception that was raised.
146+
147+
Returns:
148+
JSON error response with 500 status code and generic error message.
149+
"""
150+
return JSONResponse(
151+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "internal server error"}
152+
)
153+
135154
# Add routers
136155
app.include_router(topics.router)
137156
app.include_router(subscriptions.router)

fastpubsub/services/auth.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Authentication and authorization services for fastpubsub."""
22

3+
import time
34
from typing import Annotated
45

56
from fastapi import Depends, Request
@@ -8,8 +9,11 @@
89
from fastpubsub import services
910
from fastpubsub.config import settings
1011
from fastpubsub.exceptions import InvalidClientToken
12+
from fastpubsub.logger import get_logger
1113
from fastpubsub.models import DecodedClientToken
1214

15+
logger = get_logger(__name__)
16+
1317
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/oauth/token", auto_error=False)
1418

1519

@@ -54,9 +58,46 @@ async def get_current_token(token: str | None = Depends(oauth2_scheme)) -> Decod
5458
Raises:
5559
InvalidClientToken: If token is invalid or authentication fails.
5660
"""
57-
if token is None:
58-
token = ""
59-
return await services.decode_jwt_client_token(token, auth_enabled=settings.auth_enabled)
61+
start_time = time.perf_counter()
62+
63+
try:
64+
if token is None:
65+
token = ""
66+
67+
decoded_token = await services.decode_jwt_client_token(token, auth_enabled=settings.auth_enabled)
68+
69+
duration = time.perf_counter() - start_time
70+
logger.debug(
71+
"token validated",
72+
extra={
73+
"client_id": str(decoded_token.client_id),
74+
"scopes": list(decoded_token.scopes),
75+
"duration": f"{duration:.4f}s",
76+
},
77+
)
78+
return decoded_token
79+
except InvalidClientToken as e:
80+
duration = time.perf_counter() - start_time
81+
logger.warning(
82+
"token validation failed",
83+
extra={
84+
"error": str(e),
85+
"has_token": token is not None and token != "",
86+
"duration": f"{duration:.4f}s",
87+
},
88+
)
89+
raise
90+
except Exception as e:
91+
duration = time.perf_counter() - start_time
92+
logger.error(
93+
"token validation error",
94+
extra={
95+
"error": str(e),
96+
"has_token": token is not None and token != "",
97+
"duration": f"{duration:.4f}s",
98+
},
99+
)
100+
raise
60101

61102

62103
def require_scope(resource: str, action: str):

fastpubsub/services/clients.py

Lines changed: 152 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import datetime
44
import secrets
5+
import time
56
import uuid
67

78
from jose import jwt
@@ -13,6 +14,7 @@
1314
from fastpubsub.database import Client as DBClient
1415
from fastpubsub.database import SessionLocal
1516
from fastpubsub.exceptions import InvalidClient
17+
from fastpubsub.logger import get_logger
1618
from fastpubsub.models import (
1719
Client,
1820
ClientToken,
@@ -24,6 +26,7 @@
2426
from fastpubsub.services.helpers import _delete_entity, _get_entity, utc_now
2527

2628
password_hash = PasswordHash.recommended()
29+
logger = get_logger(__name__)
2730

2831

2932
def generate_secret() -> str:
@@ -54,25 +57,44 @@ async def create_client(data: CreateClient) -> CreateClientResult:
5457
AlreadyExistsError: If a client with the same ID already exists.
5558
ValueError: If client data validation fails.
5659
"""
57-
async with SessionLocal() as session:
58-
now = utc_now()
59-
secret = generate_secret()
60-
secret_hash = password_hash.hash(secret)
61-
db_client = DBClient(
62-
id=uuid.uuid7(),
63-
name=data.name,
64-
scopes=data.scopes,
65-
is_active=data.is_active,
66-
secret_hash=secret_hash,
67-
token_version=1,
68-
created_at=now,
69-
updated_at=now,
70-
)
71-
session.add(db_client)
72-
73-
await session.commit()
60+
start_time = time.perf_counter()
61+
logger.info(
62+
"creating client",
63+
extra={"client_name": data.name, "scopes": data.scopes, "is_active": data.is_active},
64+
)
7465

75-
return CreateClientResult(id=db_client.id, secret=secret)
66+
try:
67+
async with SessionLocal() as session:
68+
now = utc_now()
69+
secret = generate_secret()
70+
secret_hash = password_hash.hash(secret)
71+
db_client = DBClient(
72+
id=uuid.uuid7(),
73+
name=data.name,
74+
scopes=data.scopes,
75+
is_active=data.is_active,
76+
secret_hash=secret_hash,
77+
token_version=1,
78+
created_at=now,
79+
updated_at=now,
80+
)
81+
session.add(db_client)
82+
83+
await session.commit()
84+
85+
duration = time.perf_counter() - start_time
86+
logger.info(
87+
"client created",
88+
extra={"client_id": str(db_client.id), "client_name": data.name, "duration": f"{duration:.4f}s"},
89+
)
90+
return CreateClientResult(id=db_client.id, secret=secret)
91+
except Exception as e:
92+
duration = time.perf_counter() - start_time
93+
logger.error(
94+
"client creation failed",
95+
extra={"client_name": data.name, "error": str(e), "duration": f"{duration:.4f}s"},
96+
)
97+
raise
7698

7799

78100
async def get_client(client_id: uuid.UUID) -> Client:
@@ -175,31 +197,66 @@ async def issue_jwt_client_token(client_id: uuid.UUID, client_secret: str) -> Cl
175197
Raises:
176198
InvalidClient: If client credentials are invalid or client is disabled.
177199
"""
178-
async with SessionLocal() as session:
179-
db_client = await _get_entity(session, DBClient, client_id, "Client not found", raise_exception=False)
180-
if not db_client:
181-
raise InvalidClient("Client not found") from None
182-
if not db_client.is_active:
183-
raise InvalidClient("Client disabled") from None
184-
if password_hash.verify(client_secret, db_client.secret_hash) is False:
185-
raise InvalidClient("Client secret is invalid") from None
186-
187-
now = utc_now()
188-
expires_in = now + datetime.timedelta(minutes=settings.auth_access_token_expire_minutes)
189-
payload = {
190-
"sub": str(client_id),
191-
"exp": expires_in,
192-
"iat": now,
193-
"scope": db_client.scopes,
194-
"ver": db_client.token_version,
195-
}
196-
access_token = jwt.encode(payload, key=settings.auth_secret_key, algorithm=settings.auth_algorithm)
197-
198-
return ClientToken(
199-
access_token=access_token,
200-
expires_in=int((expires_in - now).total_seconds()),
201-
scope=db_client.scopes,
202-
)
200+
start_time = time.perf_counter()
201+
logger.info("issuing jwt token", extra={"client_id": str(client_id)})
202+
203+
try:
204+
async with SessionLocal() as session:
205+
db_client = await _get_entity(
206+
session, DBClient, client_id, "Client not found", raise_exception=False
207+
)
208+
if not db_client:
209+
logger.warning("token issuance failed: client not found", extra={"client_id": str(client_id)})
210+
raise InvalidClient("Client not found") from None
211+
if not db_client.is_active:
212+
logger.warning(
213+
"token issuance failed: client disabled",
214+
extra={"client_id": str(client_id), "client_name": db_client.name},
215+
)
216+
raise InvalidClient("Client disabled") from None
217+
if password_hash.verify(client_secret, db_client.secret_hash) is False:
218+
logger.warning(
219+
"token issuance failed: invalid secret",
220+
extra={"client_id": str(client_id), "client_name": db_client.name},
221+
)
222+
raise InvalidClient("Client secret is invalid") from None
223+
224+
now = utc_now()
225+
expires_in = now + datetime.timedelta(minutes=settings.auth_access_token_expire_minutes)
226+
payload = {
227+
"sub": str(client_id),
228+
"exp": expires_in,
229+
"iat": now,
230+
"scope": db_client.scopes,
231+
"ver": db_client.token_version,
232+
}
233+
access_token = jwt.encode(
234+
payload, key=settings.auth_secret_key, algorithm=settings.auth_algorithm
235+
)
236+
237+
duration = time.perf_counter() - start_time
238+
logger.info(
239+
"jwt token issued",
240+
extra={
241+
"client_id": str(client_id),
242+
"client_name": db_client.name,
243+
"scopes": db_client.scopes,
244+
"expires_in_minutes": settings.auth_access_token_expire_minutes,
245+
"duration": f"{duration:.4f}s",
246+
},
247+
)
248+
return ClientToken(
249+
access_token=access_token,
250+
expires_in=int((expires_in - now).total_seconds()),
251+
scope=db_client.scopes,
252+
)
253+
except Exception as e:
254+
duration = time.perf_counter() - start_time
255+
logger.error(
256+
"token issuance failed",
257+
extra={"client_id": str(client_id), "error": str(e), "duration": f"{duration:.4f}s"},
258+
)
259+
raise
203260

204261

205262
async def decode_jwt_client_token(access_token: str, auth_enabled: bool = True) -> DecodedClientToken:
@@ -219,28 +276,69 @@ async def decode_jwt_client_token(access_token: str, auth_enabled: bool = True)
219276
InvalidClient: If token is invalid, expired, or client is disabled/revoked.
220277
"""
221278
if not auth_enabled:
279+
logger.debug("authentication disabled, returning test token")
222280
return DecodedClientToken(client_id=uuid.uuid7(), scopes={"*"})
223281

282+
start_time = time.perf_counter()
283+
logger.debug("decoding jwt token")
284+
224285
try:
225286
payload = jwt.decode(
226287
access_token,
227288
key=settings.auth_secret_key,
228289
algorithms=[settings.auth_algorithm],
229290
)
230-
except JWTError:
291+
except JWTError as e:
292+
logger.warning("jwt token decode failed: invalid token", extra={"error": str(e)})
231293
raise InvalidClient("Invalid jwt token") from None
232294

233295
client_id = payload["sub"]
234296
scopes = payload["scope"]
235297
token_version = payload["ver"]
236298

237-
async with SessionLocal() as session:
238-
db_client = await _get_entity(session, DBClient, client_id, "Client not found", raise_exception=False)
239-
if not db_client:
240-
raise InvalidClient("Client not found") from None
241-
if not db_client.is_active:
242-
raise InvalidClient("Client disabled") from None
243-
if token_version != db_client.token_version:
244-
raise InvalidClient("Token revoked") from None
245-
246-
return DecodedClientToken(client_id=uuid.UUID(client_id), scopes={scope for scope in scopes.split()})
299+
try:
300+
async with SessionLocal() as session:
301+
db_client = await _get_entity(
302+
session, DBClient, client_id, "Client not found", raise_exception=False
303+
)
304+
if not db_client:
305+
logger.warning(
306+
"jwt token validation failed: client not found", extra={"client_id": client_id}
307+
)
308+
raise InvalidClient("Client not found") from None
309+
if not db_client.is_active:
310+
logger.warning(
311+
"jwt token validation failed: client disabled",
312+
extra={"client_id": client_id, "client_name": db_client.name},
313+
)
314+
raise InvalidClient("Client disabled") from None
315+
if token_version != db_client.token_version:
316+
logger.warning(
317+
"jwt token validation failed: token revoked",
318+
extra={
319+
"client_id": client_id,
320+
"client_name": db_client.name,
321+
"token_version": token_version,
322+
"current_version": db_client.token_version,
323+
},
324+
)
325+
raise InvalidClient("Token revoked") from None
326+
327+
duration = time.perf_counter() - start_time
328+
logger.debug(
329+
"jwt token validated",
330+
extra={
331+
"client_id": client_id,
332+
"client_name": db_client.name,
333+
"scopes": scopes,
334+
"duration": f"{duration:.4f}s",
335+
},
336+
)
337+
return DecodedClientToken(client_id=uuid.UUID(client_id), scopes={scope for scope in scopes.split()})
338+
except Exception as e:
339+
duration = time.perf_counter() - start_time
340+
logger.error(
341+
"jwt token validation failed",
342+
extra={"client_id": client_id, "error": str(e), "duration": f"{duration:.4f}s"},
343+
)
344+
raise

0 commit comments

Comments
 (0)