errors.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import html
  2. import inspect
  3. import traceback
  4. import typing
  5. from starlette._utils import is_async_callable
  6. from starlette.concurrency import run_in_threadpool
  7. from starlette.requests import Request
  8. from starlette.responses import HTMLResponse, PlainTextResponse, Response
  9. from starlette.types import ASGIApp, Message, Receive, Scope, Send
  10. STYLES = """
  11. p {
  12. color: #211c1c;
  13. }
  14. .traceback-container {
  15. border: 1px solid #038BB8;
  16. }
  17. .traceback-title {
  18. background-color: #038BB8;
  19. color: lemonchiffon;
  20. padding: 12px;
  21. font-size: 20px;
  22. margin-top: 0px;
  23. }
  24. .frame-line {
  25. padding-left: 10px;
  26. font-family: monospace;
  27. }
  28. .frame-filename {
  29. font-family: monospace;
  30. }
  31. .center-line {
  32. background-color: #038BB8;
  33. color: #f9f6e1;
  34. padding: 5px 0px 5px 5px;
  35. }
  36. .lineno {
  37. margin-right: 5px;
  38. }
  39. .frame-title {
  40. font-weight: unset;
  41. padding: 10px 10px 10px 10px;
  42. background-color: #E4F4FD;
  43. margin-right: 10px;
  44. color: #191f21;
  45. font-size: 17px;
  46. border: 1px solid #c7dce8;
  47. }
  48. .collapse-btn {
  49. float: right;
  50. padding: 0px 5px 1px 5px;
  51. border: solid 1px #96aebb;
  52. cursor: pointer;
  53. }
  54. .collapsed {
  55. display: none;
  56. }
  57. .source-code {
  58. font-family: courier;
  59. font-size: small;
  60. padding-bottom: 10px;
  61. }
  62. """
  63. JS = """
  64. <script type="text/javascript">
  65. function collapse(element){
  66. const frameId = element.getAttribute("data-frame-id");
  67. const frame = document.getElementById(frameId);
  68. if (frame.classList.contains("collapsed")){
  69. element.innerHTML = "&#8210;";
  70. frame.classList.remove("collapsed");
  71. } else {
  72. element.innerHTML = "+";
  73. frame.classList.add("collapsed");
  74. }
  75. }
  76. </script>
  77. """
  78. TEMPLATE = """
  79. <html>
  80. <head>
  81. <style type='text/css'>
  82. {styles}
  83. </style>
  84. <title>Starlette Debugger</title>
  85. </head>
  86. <body>
  87. <h1>500 Server Error</h1>
  88. <h2>{error}</h2>
  89. <div class="traceback-container">
  90. <p class="traceback-title">Traceback</p>
  91. <div>{exc_html}</div>
  92. </div>
  93. {js}
  94. </body>
  95. </html>
  96. """
  97. FRAME_TEMPLATE = """
  98. <div>
  99. <p class="frame-title">File <span class="frame-filename">{frame_filename}</span>,
  100. line <i>{frame_lineno}</i>,
  101. in <b>{frame_name}</b>
  102. <span class="collapse-btn" data-frame-id="{frame_filename}-{frame_lineno}" onclick="collapse(this)">{collapse_button}</span>
  103. </p>
  104. <div id="{frame_filename}-{frame_lineno}" class="source-code {collapsed}">{code_context}</div>
  105. </div>
  106. """ # noqa: E501
  107. LINE = """
  108. <p><span class="frame-line">
  109. <span class="lineno">{lineno}.</span> {line}</span></p>
  110. """
  111. CENTER_LINE = """
  112. <p class="center-line"><span class="frame-line center-line">
  113. <span class="lineno">{lineno}.</span> {line}</span></p>
  114. """
  115. class ServerErrorMiddleware:
  116. """
  117. Handles returning 500 responses when a server error occurs.
  118. If 'debug' is set, then traceback responses will be returned,
  119. otherwise the designated 'handler' will be called.
  120. This middleware class should generally be used to wrap *everything*
  121. else up, so that unhandled exceptions anywhere in the stack
  122. always result in an appropriate 500 response.
  123. """
  124. def __init__(
  125. self,
  126. app: ASGIApp,
  127. handler: typing.Optional[
  128. typing.Callable[[Request, Exception], typing.Any]
  129. ] = None,
  130. debug: bool = False,
  131. ) -> None:
  132. self.app = app
  133. self.handler = handler
  134. self.debug = debug
  135. async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
  136. if scope["type"] != "http":
  137. await self.app(scope, receive, send)
  138. return
  139. response_started = False
  140. async def _send(message: Message) -> None:
  141. nonlocal response_started, send
  142. if message["type"] == "http.response.start":
  143. response_started = True
  144. await send(message)
  145. try:
  146. await self.app(scope, receive, _send)
  147. except Exception as exc:
  148. request = Request(scope)
  149. if self.debug:
  150. # In debug mode, return traceback responses.
  151. response = self.debug_response(request, exc)
  152. elif self.handler is None:
  153. # Use our default 500 error handler.
  154. response = self.error_response(request, exc)
  155. else:
  156. # Use an installed 500 error handler.
  157. if is_async_callable(self.handler):
  158. response = await self.handler(request, exc)
  159. else:
  160. response = await run_in_threadpool(self.handler, request, exc)
  161. if not response_started:
  162. await response(scope, receive, send)
  163. # We always continue to raise the exception.
  164. # This allows servers to log the error, or allows test clients
  165. # to optionally raise the error within the test case.
  166. raise exc
  167. def format_line(
  168. self, index: int, line: str, frame_lineno: int, frame_index: int
  169. ) -> str:
  170. values = {
  171. # HTML escape - line could contain < or >
  172. "line": html.escape(line).replace(" ", "&nbsp"),
  173. "lineno": (frame_lineno - frame_index) + index,
  174. }
  175. if index != frame_index:
  176. return LINE.format(**values)
  177. return CENTER_LINE.format(**values)
  178. def generate_frame_html(self, frame: inspect.FrameInfo, is_collapsed: bool) -> str:
  179. code_context = "".join(
  180. self.format_line(
  181. index,
  182. line,
  183. frame.lineno,
  184. frame.index, # type: ignore[arg-type]
  185. )
  186. for index, line in enumerate(frame.code_context or [])
  187. )
  188. values = {
  189. # HTML escape - filename could contain < or >, especially if it's a virtual
  190. # file e.g. <stdin> in the REPL
  191. "frame_filename": html.escape(frame.filename),
  192. "frame_lineno": frame.lineno,
  193. # HTML escape - if you try very hard it's possible to name a function with <
  194. # or >
  195. "frame_name": html.escape(frame.function),
  196. "code_context": code_context,
  197. "collapsed": "collapsed" if is_collapsed else "",
  198. "collapse_button": "+" if is_collapsed else "&#8210;",
  199. }
  200. return FRAME_TEMPLATE.format(**values)
  201. def generate_html(self, exc: Exception, limit: int = 7) -> str:
  202. traceback_obj = traceback.TracebackException.from_exception(
  203. exc, capture_locals=True
  204. )
  205. exc_html = ""
  206. is_collapsed = False
  207. exc_traceback = exc.__traceback__
  208. if exc_traceback is not None:
  209. frames = inspect.getinnerframes(exc_traceback, limit)
  210. for frame in reversed(frames):
  211. exc_html += self.generate_frame_html(frame, is_collapsed)
  212. is_collapsed = True
  213. # escape error class and text
  214. error = (
  215. f"{html.escape(traceback_obj.exc_type.__name__)}: "
  216. f"{html.escape(str(traceback_obj))}"
  217. )
  218. return TEMPLATE.format(styles=STYLES, js=JS, error=error, exc_html=exc_html)
  219. def generate_plain_text(self, exc: Exception) -> str:
  220. return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
  221. def debug_response(self, request: Request, exc: Exception) -> Response:
  222. accept = request.headers.get("accept", "")
  223. if "text/html" in accept:
  224. content = self.generate_html(exc)
  225. return HTMLResponse(content, status_code=500)
  226. content = self.generate_plain_text(exc)
  227. return PlainTextResponse(content, status_code=500)
  228. def error_response(self, request: Request, exc: Exception) -> Response:
  229. return PlainTextResponse("Internal Server Error", status_code=500)