errors.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  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[typing.Callable] = None,
  128. debug: bool = False,
  129. ) -> None:
  130. self.app = app
  131. self.handler = handler
  132. self.debug = debug
  133. async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
  134. if scope["type"] != "http":
  135. await self.app(scope, receive, send)
  136. return
  137. response_started = False
  138. async def _send(message: Message) -> None:
  139. nonlocal response_started, send
  140. if message["type"] == "http.response.start":
  141. response_started = True
  142. await send(message)
  143. try:
  144. await self.app(scope, receive, _send)
  145. except Exception as exc:
  146. request = Request(scope)
  147. if self.debug:
  148. # In debug mode, return traceback responses.
  149. response = self.debug_response(request, exc)
  150. elif self.handler is None:
  151. # Use our default 500 error handler.
  152. response = self.error_response(request, exc)
  153. else:
  154. # Use an installed 500 error handler.
  155. if is_async_callable(self.handler):
  156. response = await self.handler(request, exc)
  157. else:
  158. response = await run_in_threadpool(self.handler, request, exc)
  159. if not response_started:
  160. await response(scope, receive, send)
  161. # We always continue to raise the exception.
  162. # This allows servers to log the error, or allows test clients
  163. # to optionally raise the error within the test case.
  164. raise exc
  165. def format_line(
  166. self, index: int, line: str, frame_lineno: int, frame_index: int
  167. ) -> str:
  168. values = {
  169. # HTML escape - line could contain < or >
  170. "line": html.escape(line).replace(" ", "&nbsp"),
  171. "lineno": (frame_lineno - frame_index) + index,
  172. }
  173. if index != frame_index:
  174. return LINE.format(**values)
  175. return CENTER_LINE.format(**values)
  176. def generate_frame_html(self, frame: inspect.FrameInfo, is_collapsed: bool) -> str:
  177. code_context = "".join(
  178. self.format_line(
  179. index, line, frame.lineno, frame.index # type: ignore[arg-type]
  180. )
  181. for index, line in enumerate(frame.code_context or [])
  182. )
  183. values = {
  184. # HTML escape - filename could contain < or >, especially if it's a virtual
  185. # file e.g. <stdin> in the REPL
  186. "frame_filename": html.escape(frame.filename),
  187. "frame_lineno": frame.lineno,
  188. # HTML escape - if you try very hard it's possible to name a function with <
  189. # or >
  190. "frame_name": html.escape(frame.function),
  191. "code_context": code_context,
  192. "collapsed": "collapsed" if is_collapsed else "",
  193. "collapse_button": "+" if is_collapsed else "&#8210;",
  194. }
  195. return FRAME_TEMPLATE.format(**values)
  196. def generate_html(self, exc: Exception, limit: int = 7) -> str:
  197. traceback_obj = traceback.TracebackException.from_exception(
  198. exc, capture_locals=True
  199. )
  200. exc_html = ""
  201. is_collapsed = False
  202. exc_traceback = exc.__traceback__
  203. if exc_traceback is not None:
  204. frames = inspect.getinnerframes(exc_traceback, limit)
  205. for frame in reversed(frames):
  206. exc_html += self.generate_frame_html(frame, is_collapsed)
  207. is_collapsed = True
  208. # escape error class and text
  209. error = (
  210. f"{html.escape(traceback_obj.exc_type.__name__)}: "
  211. f"{html.escape(str(traceback_obj))}"
  212. )
  213. return TEMPLATE.format(styles=STYLES, js=JS, error=error, exc_html=exc_html)
  214. def generate_plain_text(self, exc: Exception) -> str:
  215. return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
  216. def debug_response(self, request: Request, exc: Exception) -> Response:
  217. accept = request.headers.get("accept", "")
  218. if "text/html" in accept:
  219. content = self.generate_html(exc)
  220. return HTMLResponse(content, status_code=500)
  221. content = self.generate_plain_text(exc)
  222. return PlainTextResponse(content, status_code=500)
  223. def error_response(self, request: Request, exc: Exception) -> Response:
  224. return PlainTextResponse("Internal Server Error", status_code=500)