Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

drop error_response, add error detail and support for serialization #59

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
15 changes: 15 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ This project adheres to `Semantic Versioning <http://semver.org/>`_.

Latest release

--------
`0.4.0`_
--------
*Release date: TBD*

* BREAKING CHANGE: drop support for `default_error_response` in the middleware. Now instead of passing default response
for error you can pass `error_handler` function that accepts the request object and `StarletteContextException`
so it's easier to customize the exception depending on the case
* BREAKING CHANGE: plugins don't accept `error_response` anymore. This can be handled in the middleware using
`error_handler` mentioned above. All starlette-context errors will be passed to this handler
* some errors didn't provide meaningful message. Now all of them should return explicit message as to what went wrong.
Additionally, middleware now accepts `log_errors` boolean (default: `False`). If `True`, all errors
will be additionally logged before they are handled.


--------
`0.3.3`_
--------
Expand Down
6 changes: 6 additions & 0 deletions docs/source/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
Errors
======

**************
Error handling
**************

Due to ``starlette`` structure, it's impossible to raise an exception in the middleware (what this library does) and let it be
handled in ``starlette`` error handler. For that, you will have to use another error handler. For details, look at middlewares section.
************************
ContextDoesNotExistError
************************
Expand Down
25 changes: 12 additions & 13 deletions docs/source/middleware.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,25 @@ More usage detail along with code examples can be found in :doc:`/plugins`.
Errors and Middlewares in Starlette
***********************************

There may be a validation error occuring while processing the request in the plugins, which requires sending an error response.
Starlette however does not let middleware use the regular error handler (`more details <https://www.starlette.io/exceptions/#errors-and-handled-exceptions>`_),
so middlewares facing a validation error have to send a response by themselves.
There may be a validation error occurring while processing the request in the plugins, which requires sending an error response.
Starlette however does not let middleware use the regular error handler (`more details <https://www.starlette.io/exceptions/#errors-and-handled-exceptions>`_).
Instead, this library supports error handler that's identical to what you can find in ``starlette``. Middleware accepts ``error_handler`` parameter.
It has to be an awaitable coroutine that accepts request and exception arguments.

By default, the response sent will be a 400 with no body or extra header, as a Starlette ``Response(status_code=400)``.
This response can be customized at both middleware and plugin level.

The middlewares accepts a ``Response`` object (or anything that inherits it, such as a ``JSONResponse``) through ``default_error_response`` keyword argument at init.
This response will be sent on raised ``starlette_context.errors.MiddleWareValidationError`` exceptions, if it doesn't include a response itself.
By default all internal errors are returned as ``PlainTextResponse``.

.. code-block:: python

# how to return JSON error instead of plain text
def error_handler(request, exc):
return JSONResponse(
{"error_message": exc.detail}, status_code=exc.status_code
)

middleware = [
Middleware(
ContextMiddleware,
default_error_response=JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
content={"Error": "Invalid request"},
),
# plugins = ...
error_handler=error_handler
)
]

Expand Down
14 changes: 1 addition & 13 deletions docs/source/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,7 @@ Using a plugin

You may add as many plugins as you want to your middleware. You pass them to the middleware accordingly to the Starlette standard.

There may be a validation error occuring while processing the request in the plugins, which requires sending an error response.
Starlette however does not let middleware use the regular error handler
(`more details on this <https://www.starlette.io/exceptions/#errors-and-handled-exceptions>`_),
so middlewares facing a validation error have to send a response by themselves.

By default, the response sent will be a 400 with no body or extra header, as a Starlette `Response(status_code=400)`.
This response can be customized at both middleware and plugin level.
Plugin errors are handled in the middleware. For more information, see middlewares section.

*************
Example usage
Expand Down Expand Up @@ -127,12 +121,6 @@ You need to override the ``process_request`` method.
This gives you full access to the request, freedom to perform any processing in-between, and to return any value type.
Whatever is returned will be put in the context, again with the plugin's defined ``key``.

Any Exception raised from a middleware in Starlette would normally become a hard 500 response.
However you probably might find cases where you want to send a validation error instead.
For those cases, ``starlette_context`` provides a ``MiddleWareValidationError`` exception you can raise, and include a Starlette ``Response`` object.
The middleware class will take care of sending it.
You can also raise a MiddleWareValidationError without attaching a response, the middleware's default response will then be used.

You can also do more than extracting from requests, plugins also have a hook to modify the response before it's sent: ``enrich_response``.
It can access the Response object, and of course, the context, fully populated by that point.

Expand Down
5 changes: 4 additions & 1 deletion example/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ async def dispatch(
]


app = Starlette(debug=True, middleware=middlewares)
app = Starlette(
debug=False,
middleware=middlewares,
)


@app.on_event("startup")
Expand Down
50 changes: 29 additions & 21 deletions starlette_context/errors.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,44 @@
from starlette.exceptions import HTTPException
from typing import Optional
from starlette.responses import Response
from starlette import status


class StarletteContextError(BaseException):
pass
class StarletteContextException(HTTPException):
status_code = status.HTTP_400_BAD_REQUEST
detail = "Error"

def __init__(
self,
detail: Optional[str] = None,
status_code: Optional[int] = None,
) -> None:
super().__init__(
status_code=status_code or self.status_code,
detail=detail or self.detail,
)


class ContextDoesNotExistError(RuntimeError, StarletteContextError):
def __init__(self):
self.message = (
class ContextDoesNotExistError(StarletteContextException):
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR

def __str__(self): # pragma: no cover
return (
"You didn't use the required middleware or "
"you're trying to access `context` object "
"outside of the request-response cycle."
"outside of the request-response cycle"
)
super().__init__(self.message)


class ConfigurationError(StarletteContextError):
pass

class ConfigurationError(StarletteContextException):
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR

class MiddleWareValidationError(StarletteContextError):
def __init__(
self, *args, error_response: Optional[Response] = None
) -> None:
super().__init__(*args)
self.error_response = error_response
def __str__(self): # pragma: no cover
return "Invalid starlette-context configuration"


class WrongUUIDError(MiddleWareValidationError):
pass
class WrongUUIDError(StarletteContextException):
detail = "Invalid UUID in request header"


class DateFormatError(MiddleWareValidationError):
pass
class DateFormatError(StarletteContextException):
detail = "Date header in wrong format, has to match rfc1123"
33 changes: 15 additions & 18 deletions starlette_context/middleware/context_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
from starlette.responses import Response

from starlette_context import _request_scope_context_storage
from starlette_context.middleware.mixin import StarletteContextMiddlewareMixin
from starlette_context.plugins import Plugin
from starlette_context.errors import (
ConfigurationError,
MiddleWareValidationError,
StarletteContextException,
)


class ContextMiddleware(BaseHTTPMiddleware):
class ContextMiddleware(BaseHTTPMiddleware, StarletteContextMiddlewareMixin):
"""
Middleware that creates empty context for request it's used on. If not
used, you won't be able to use context object.
Expand All @@ -28,18 +29,18 @@ class ContextMiddleware(BaseHTTPMiddleware):
def __init__(
self,
plugins: Optional[Sequence[Plugin]] = None,
default_error_response: Response = Response(status_code=400),
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
BaseHTTPMiddleware.__init__(
self, app=kwargs["app"], dispatch=kwargs.get("dispatch")
)
StarletteContextMiddlewareMixin.__init__(self, **kwargs)
for plugin in plugins or ():
if not isinstance(plugin, Plugin):
raise ConfigurationError(
f"Plugin {plugin} is not a valid instance"
)
self.plugins = plugins or ()
self.error_response = default_error_response

async def set_context(self, request: Request) -> dict:
"""
Expand All @@ -59,19 +60,15 @@ async def dispatch(
try:
context = await self.set_context(request)
token: Token = _request_scope_context_storage.set(context)
except MiddleWareValidationError as e:
if e.error_response:
error_response = e.error_response
else:
error_response = self.error_response
return error_response

try:
response = await call_next(request)
for plugin in self.plugins:
await plugin.enrich_response(response)
try:
response = await call_next(request)
for plugin in self.plugins:
await plugin.enrich_response(response)
finally:
_request_scope_context_storage.reset(token)

finally:
_request_scope_context_storage.reset(token)
except StarletteContextException as e:
response = await self.create_response_from_exception(request, e)

return response
56 changes: 56 additions & 0 deletions starlette_context/middleware/mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import logging
from typing import Awaitable, Callable, Optional, Union

from starlette_context.errors import StarletteContextException
from starlette.responses import PlainTextResponse, Response
from starlette.requests import Request


class StarletteContextMiddlewareMixin:
def __init__(
self,
error_handler: Optional[
Callable[
[Request, StarletteContextException],
Union[Response, Awaitable[Response]],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return await self.error_handler(request, exc)
that looks very much like only awaitables are supported. why the union?
(and i'd rather support only one than digging into the can of worms of supporting either)

]
] = None,
log_errors: bool = False,
*args,
**kwargs,
):
self.error_handler = error_handler or self.default_error_handler
self.log_errors = log_errors

async def log_error(
self, request: Request, exc: StarletteContextException
) -> None:
if self.log_errors:
logger = self.get_logger()
logger.exception("starlette-context exception")

@staticmethod
async def default_error_handler(
request: Request, exc: StarletteContextException
) -> Response:
return PlainTextResponse(
content=exc.detail, status_code=exc.status_code
)

@staticmethod
def get_logger():
return logging.getLogger("starlette_context") # pragma: no cover

async def create_response_from_exception(
self, request: Request, exc: StarletteContextException
) -> Response:
"""
Middleware / plugins exceptions will always result in 500 plain text
errors.

These errors can't be handled using exception handlers passed to the
app instance / middleware. If you need to customize the response,
handle it here.
"""
await self.log_error(request, exc)
return await self.error_handler(request, exc)
45 changes: 23 additions & 22 deletions starlette_context/middleware/raw_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,25 @@
from typing import Optional, Sequence, Union

from starlette.requests import HTTPConnection, Request
from starlette.responses import Response
from starlette.types import ASGIApp, Message, Receive, Scope, Send

from starlette_context import _request_scope_context_storage
from starlette_context.middleware.mixin import StarletteContextMiddlewareMixin
from starlette_context.plugins import Plugin
from starlette_context.errors import (
ConfigurationError,
MiddleWareValidationError,
StarletteContextException,
)


class RawContextMiddleware:
class RawContextMiddleware(StarletteContextMiddlewareMixin):
def __init__(
self,
app: ASGIApp,
plugins: Optional[Sequence[Plugin]] = None,
default_error_response: Response = Response(status_code=400),
**kwargs,
) -> None:
super().__init__(**kwargs)
self.app = app
for plugin in plugins or ():
if not isinstance(plugin, Plugin):
Expand All @@ -28,7 +29,6 @@ def __init__(
)

self.plugins = plugins or ()
self.error_response = default_error_response

async def set_context(
self, request: Union[Request, HTTPConnection]
Expand Down Expand Up @@ -70,29 +70,30 @@ async def send_wrapper(message: Message) -> None:

try:
context = await self.set_context(request)
except MiddleWareValidationError as e:
if e.error_response is not None:
error_response = e.error_response
else:
error_response = self.error_response
token: Token = _request_scope_context_storage.set(context)

message_head = {
try:
await self.app(scope, receive, send_wrapper)
finally:
_request_scope_context_storage.reset(token)

except StarletteContextException as e:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moving the handling to wrap everything? no, that can't be right.
StarletteContextException should only happen during plugin execution anyway.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well I'm glad I didn't merge it yet. Thanks!

response = await self.create_response_from_exception(request, e)

message_head: Message = {
"type": "http.response.start",
"status": error_response.status_code,
"headers": error_response.raw_headers,
"status": response.status_code,
"headers": [
(k.encode(), v.encode())
for k, v in response.headers.items()
],
}

await send(message_head)

message_body = {
message_body: Message = {
"type": "http.response.body",
"body": error_response.body,
"body": response.body,
}
await send(message_body)
return

token: Token = _request_scope_context_storage.set(context)

try:
await self.app(scope, receive, send_wrapper)
finally:
_request_scope_context_storage.reset(token)
Loading