errors.py 7.9 KB

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