Coverage for fastapi_restly / _exception_handlers.py: 91%

84 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-09 18:41 +0000

1"""Default FastAPI exception handlers installed by fastapi-restly. 

2 

3Currently this module provides a translation layer from SQLAlchemy 

4:class:`~sqlalchemy.exc.IntegrityError` (unique-constraint, foreign-key, 

5not-null, and check-constraint violations) into a clean HTTP 409 Conflict 

6response. Without this handler, an ``IntegrityError`` bubbles up to FastAPI 

7and turns into a 500 Internal Server Error, which is misleading for clients 

8(the server is fine; the request conflicts with the current state of the 

9resource). 

10 

11The handler is installed automatically by :func:`~fastapi_restly.db.configure` 

12and as a fallback by :func:`~fastapi_restly.views.include_view`. Users can opt out 

13by calling ``fr.configure(install_default_exception_handlers=False)`` or by 

14registering their own handler for ``IntegrityError`` *before* the framework 

15gets a chance to install one. 

16 

17The detail-message extraction is best-effort: it understands the most common 

18PostgreSQL SQLSTATE codes (via ``orig.sqlstate`` / ``orig.pgcode``) and the 

19SQLite error-message conventions. For unrecognised dialects/messages we fall back 

20to a generic conflict message that includes a truncated version of the 

21underlying error text. 

22""" 

23 

24from __future__ import annotations 

25 

26from typing import Any 

27 

28from fastapi import FastAPI, Request 

29from fastapi.responses import JSONResponse 

30from sqlalchemy.exc import IntegrityError 

31 

32# Maximum length of original-error text we are willing to echo back. Keeps 

33# response bodies sane and avoids accidentally leaking long SQL strings. 

34_MAX_ORIG_TEXT_LENGTH = 500 

35 

36# Marker stored on ``app.state`` so we know we've already installed our 

37# handlers on this FastAPI instance. Public so it is easy to inspect from 

38# tests or user code. 

39_HANDLERS_INSTALLED_FLAG = "_fr_default_exception_handlers_installed" 

40 

41 

42# --------------------------------------------------------------------------- 

43# Detail extraction 

44# --------------------------------------------------------------------------- 

45 

46 

47# PostgreSQL SQLSTATE codes — see 

48# https://www.postgresql.org/docs/current/errcodes-appendix.html (class 23 

49# "Integrity Constraint Violation"). 

50_PG_SQLSTATE_DETAILS: dict[str, str] = { 

51 "23505": "Unique constraint violated", 

52 "23503": "Foreign key constraint violated", 

53 "23502": "Not-null constraint violated", 

54 "23514": "Check constraint violated", 

55 "23000": "Integrity constraint violated", 

56 "23001": "Restrict violation", 

57 "23P01": "Exclusion constraint violated", 

58} 

59 

60 

61def _extract_postgres_detail(orig: Any) -> str | None: 

62 """Return a user-facing detail message for a Postgres-driver error. 

63 

64 Reads the SQLSTATE code and, when psycopg's ``orig.diag`` is present, its 

65 ``constraint_name`` / ``column_name`` to enrich the message. The attribute 

66 holding the code differs by driver: psycopg 3 exposes ``sqlstate``, psycopg 2 

67 exposes ``pgcode`` -- read whichever is present. Reading only ``pgcode`` 

68 silently degraded every psycopg 3 error to the generic fallback. 

69 """ 

70 pgcode = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None) 

71 if not pgcode: 

72 return None 

73 

74 base = _PG_SQLSTATE_DETAILS.get(pgcode) 

75 if base is None: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 return None 

77 

78 # ``diag`` is a psycopg-specific attribute holding fielded error info. 

79 diag = getattr(orig, "diag", None) 

80 constraint_name = getattr(diag, "constraint_name", None) if diag else None 

81 column_name = getattr(diag, "column_name", None) if diag else None 

82 

83 if pgcode == "23505" and constraint_name: 

84 return f"{base}: {constraint_name}" 

85 if pgcode == "23503" and constraint_name: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 return f"{base}: {constraint_name}" 

87 if pgcode == "23502" and column_name: 

88 return f"{base} on column {column_name!r}" 

89 if pgcode == "23514" and constraint_name: 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true

90 return f"{base}: {constraint_name}" 

91 return base 

92 

93 

94# Mapping from SQLite error-message prefixes to a clean detail message. 

95# SQLite's IntegrityError.args[0] (and ``str(orig)``) follow predictable 

96# patterns, e.g. ``"UNIQUE constraint failed: user.username"``. 

97_SQLITE_PREFIX_DETAILS: tuple[tuple[str, str], ...] = ( 

98 ("UNIQUE constraint failed:", "Unique constraint violated"), 

99 ("FOREIGN KEY constraint failed", "Foreign key constraint violated"), 

100 ("NOT NULL constraint failed:", "Not-null constraint violated"), 

101 ("CHECK constraint failed:", "Check constraint violated"), 

102 ("PRIMARY KEY must be unique", "Unique constraint violated (primary key)"), 

103) 

104 

105 

106def _extract_sqlite_detail(orig: Any) -> str | None: 

107 """Return a user-facing detail message for a SQLite-driver error.""" 

108 text = str(orig).strip() 

109 if not text: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 return None 

111 

112 for prefix, base in _SQLITE_PREFIX_DETAILS: 

113 if not text.startswith(prefix): 

114 continue 

115 # Try to surface the column / constraint info that SQLite tacks on 

116 # after the colon. ``UNIQUE constraint failed: user.username`` → 

117 # ``"Unique constraint violated on user.username"``. 

118 remainder = text[len(prefix) :].strip().lstrip(":").strip() 

119 if remainder: 

120 return f"{base} on {remainder}" 

121 return base 

122 return None 

123 

124 

125def _build_integrity_detail(exc: IntegrityError) -> str: 

126 """Build a clean HTTP 409 detail message from a SQLAlchemy IntegrityError. 

127 

128 Best-effort across dialects: 

129 

130 * PostgreSQL — switches on the SQLSTATE code (``exc.orig.sqlstate`` / 

131 ``pgcode``, class 23). 

132 * SQLite — pattern-matches ``str(exc.orig)`` against known prefixes. 

133 * Anything else — returns a generic fallback that includes a truncated 

134 copy of the original error text so the body is still useful for 

135 debugging without being huge. 

136 """ 

137 orig = getattr(exc, "orig", None) 

138 if orig is not None: 138 ↛ 149line 138 didn't jump to line 149 because the condition on line 138 was always true

139 pg_detail = _extract_postgres_detail(orig) 

140 if pg_detail is not None: 

141 return pg_detail 

142 

143 sqlite_detail = _extract_sqlite_detail(orig) 

144 if sqlite_detail is not None: 

145 return sqlite_detail 

146 

147 # Generic fallback. Prefer the original driver error text (it's usually 

148 # the most informative); truncate so we don't dump a giant SQL statement. 

149 raw = str(orig) if orig is not None else str(exc) 

150 raw = raw.strip() 

151 if len(raw) > _MAX_ORIG_TEXT_LENGTH: 

152 raw = raw[:_MAX_ORIG_TEXT_LENGTH] + "...(truncated)" 

153 

154 base = "Conflict with current state of the resource" 

155 if raw: 155 ↛ 157line 155 didn't jump to line 157 because the condition on line 155 was always true

156 return f"{base}: {raw}" 

157 return base 

158 

159 

160# --------------------------------------------------------------------------- 

161# The handler & registration helper 

162# --------------------------------------------------------------------------- 

163 

164 

165def integrity_error_handler(request: Request, exc: Exception) -> JSONResponse: 

166 """Translate a SQLAlchemy IntegrityError into HTTP 409 Conflict. 

167 

168 Signature accepts ``Exception`` instead of ``IntegrityError`` to satisfy 

169 Starlette's exception-handler typing; runtime code narrows it. 

170 """ 

171 assert isinstance(exc, IntegrityError) # noqa: S101 - registered for IntegrityError only 

172 detail = _build_integrity_detail(exc) 

173 return JSONResponse(status_code=409, content={"detail": detail}) 

174 

175 

176def register_default_exception_handlers(app: FastAPI) -> None: 

177 """Idempotently install fastapi-restly default exception handlers on ``app``. 

178 

179 * Skips if a handler for :class:`~sqlalchemy.exc.IntegrityError` is already 

180 registered on ``app`` — we always defer to the user. 

181 * Skips if we have already installed handlers on this ``app`` instance 

182 (so calling from both :func:`~fastapi_restly.db.configure` and 

183 :func:`~fastapi_restly.views.include_view` is safe). 

184 """ 

185 if getattr(app.state, _HANDLERS_INSTALLED_FLAG, False): 

186 return 

187 

188 # Respect a user-registered handler if one is already in place. 

189 if IntegrityError in app.exception_handlers: 

190 setattr(app.state, _HANDLERS_INSTALLED_FLAG, True) 

191 return 

192 

193 app.add_exception_handler(IntegrityError, integrity_error_handler) 

194 setattr(app.state, _HANDLERS_INSTALLED_FLAG, True) 

195 _register_on_built_stack(app) 

196 

197 

198def _register_on_built_stack(app: FastAPI) -> None: 

199 """Also register on an application that has already been called. 

200 

201 Starlette builds its middleware stack on the first call and the stack caches 

202 the handler map, so a view registered after that would leave this handler out 

203 and an ``IntegrityError`` would surface as a 500. Entering a test client to 

204 run the lifespan is enough to build it. 

205 

206 Reach the ``ExceptionMiddleware`` already in the stack rather than discarding 

207 the stack to force a rebuild: rebuilding constructs every user middleware 

208 again, dropping whatever state the live instances hold, and it would briefly 

209 disarm Starlette's own "cannot add middleware after startup" check. 

210 """ 

211 stack = getattr(app, "middleware_stack", None) 

212 if stack is None: 

213 return 

214 

215 from starlette.middleware.exceptions import ExceptionMiddleware 

216 

217 node: Any = stack 

218 while node is not None: 

219 if isinstance(node, ExceptionMiddleware): 

220 node.add_exception_handler(IntegrityError, integrity_error_handler) 

221 return 

222 node = getattr(node, "app", None) 

223 

224 import warnings 

225 

226 # A middleware that stores its wrapped app under another name ends the walk. 

227 # Rare, but the alternative is unexplained 500s on late-registered views. 

228 warnings.warn( 

229 "Restly could not reach Starlette's ExceptionMiddleware on the built " 

230 "middleware stack: a middleware in the chain does not expose its " 

231 "wrapped app as `.app`. Views registered after the application first " 

232 "ran will surface IntegrityError as a 500 rather than a 409.", 

233 RuntimeWarning, 

234 stacklevel=2, 

235 ) 

236 

237 

238__all__ = ["integrity_error_handler", "register_default_exception_handlers"]