Errors & Exception Views
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Pyramid 2.1 (the 2.1-branch documentation and the 2.1 release installed from PyPI) on
Python 3.13, checked on 2026-10-07, with pyramid_tm 2.6 and pyramid_retry 2.1.1 for the transaction section. Every
example that does not say otherwise was executed with webtest.TestApp. The running example is the Bookshelf Shelves
scenario: public shelves owned by users, which grow over the pages of this section. This page covers how a Pyramid
application reports problems: a missing shelf, a forbidden edit, a rejected form, a database that goes away.
Most frameworks give error handling one mechanism. Flask has @app.errorhandler
(Flask: Error Handling), Tornado has write_error
(Tornado: Error Handling), Django has the handler404 family
(Django: Logging & Error Reporting) and FastAPI has
@app.exception_handler
(FastAPI: Status Codes & Error Handling). Pyramid has a single idea behind all of them: an exception view is an ordinary view whose context is an
exception. Raising HTTPNotFound, a permission failure, a domain error such as ShelfFull and a plain
RuntimeError all go through the same lookup machinery as a normal request, with the same predicates, renderers and
request object. Once that idea is clear, the "special" cases (the Not Found View, the Forbidden View, the default
response for HTTP exceptions) are only exception views that Pyramid registers or lets you replace.
The Big Picture
Four pieces cooperate, and it helps to name them before looking at each one.
-
HTTP exceptions are the classes in
pyramid.httpexceptions. Each is both a Python exception and a WSGI response, so a view mayreturnone orraiseone. -
Exception views are views registered for an exception class. When an exception escapes a view (or a root factory, traverser or predicate), Pyramid looks up the most specific exception view that matches the exception and the request and uses its response.
-
The excview tween (
pyramid.tweens.excview_tween_factory) is the piece of the request pipeline that performs that lookup. It sits between the router and the main handler, which is why the Pyramid request pipeline shown on the introduction page of this section lists it as a wrapper around everything below it. -
The Not Found View and the Forbidden View are exception views for
HTTPNotFoundandHTTPForbidden, the two HTTP exceptions Pyramid raises on its own.
An exception that no exception view handles is re-raised, travels out of the tweens and reaches the WSGI server, which answers 500 and logs the traceback. That is the right behaviour for bugs in development, and the wrong one for a production API, which is why most applications register a catch-all exception view (see JSON Error Responses for an API).
HTTP Exceptions
All the classes of pyramid.httpexceptions that inherit from HTTPException are HTTP exception objects. Each one
relates to a single status code, is a Response (so it has headers, a body and a content type) and is also an
Exception (so it can be raised). The base classes group them: HTTPSuccessful (2xx), HTTPRedirection (3xx),
HTTPError (4xx and 5xx, subdivided into HTTPClientError and HTTPServerError). The official documentation states
that an HTTPError signals that any work in progress should not be committed, a hint that matters for the
transaction section below.
| Class | Status | Typical use in Bookshelf Shelves |
|---|---|---|
|
302 |
Redirect after a successful form submission; requires |
|
303 |
Redirect after a POST so that a reload does not resubmit. |
|
307, 308 |
Redirects that keep the request method (308 added in Pyramid 1.10). |
|
204 |
Successful |
|
400 |
Malformed input. |
|
401 |
Not authenticated. |
|
403 |
Authenticated, but not allowed. Pyramid raises it on a failed permission check. |
|
404 |
The shelf does not exist. Pyramid raises it when no view matches. |
|
409 |
Duplicate shelf name. |
|
422 |
Input is well-formed but semantically invalid. |
|
429 |
Rate limiting. |
|
500, 503 |
Server-side failures that you decide to report explicitly. |
|
418 |
Added in Pyramid 2.1. |
Returning or raising
The official guide states the rule precisely: instances of an HTTP exception object may either be returned or raised
from within view code, and in both cases the instance is used as the view’s response. Raising is usually the more
convenient form, because it stops the view at the point of failure from any depth of helper functions.
exception_response(status_code, **kw) is a shortcut that builds the right class from a number, which is handy when
the code comes from a variable.
from pyramid.config import Configurator
from pyramid.httpexceptions import (
HTTPConflict,
HTTPFound,
HTTPNoContent,
HTTPNotFound,
exception_response,
)
from webtest import TestApp
SHELVES = {1: "Ada's shelf", 2: "Grace's shelf"}
def show_shelf(request):
shelf_id = int(request.matchdict["id"])
try:
return {"id": shelf_id, "name": SHELVES[shelf_id]}
except KeyError:
raise HTTPNotFound(f"No shelf {shelf_id}")
def old_shelf(request):
# returned, not raised: both forms produce the same response
return HTTPFound(location=request.route_url("shelf", id=1))
def create_shelf(request):
raise HTTPConflict(detail="Shelf name taken", headers={"X-Reason": "duplicate"})
def delete_shelf(request):
return HTTPNoContent()
def teapot(request):
raise exception_response(418)
with Configurator() as config:
config.add_route("shelf", "/shelves/{id:\\d+}")
config.add_route("old", "/old")
config.add_route("create", "/create")
config.add_route("delete", "/delete")
config.add_route("teapot", "/teapot")
config.add_view(show_shelf, route_name="shelf", renderer="json")
config.add_view(old_shelf, route_name="old")
config.add_view(create_shelf, route_name="create")
config.add_view(delete_shelf, route_name="delete")
config.add_view(teapot, route_name="teapot")
app = config.make_wsgi_app()
client = TestApp(app)
print(client.get("/shelves/1").json)
print(client.get("/old", status=302).location)
print(client.get("/create", status=409).headers["X-Reason"])
print(client.get("/delete", status=204).status)
print(client.get("/teapot", status=418).status)
missing = client.get("/shelves/9", status=404)
print(missing.status, missing.content_type)
The first request prints the shelf, the redirect prints http://localhost/shelves/1, and the 404 answers with the
content type text/plain: the test client sends no Accept header, and text/plain is the fallback described next.
Source: Pyramid: Views (Using Special Exceptions in View Callables) and pyramid.httpexceptions
In a route pattern such as /shelves/{id:\d}+ the part after the colon is a regular expression, so a request for
/shelves/abc never matches the route and ends in the Not Found View instead of in your view; see
URL Dispatch.
What the response body looks like
A bare HTTPNotFound() is not an empty 404: the class renders a body when the response is prepared. Since Pyramid 1.7 it
looks at the client’s Accept header and picks text/html, application/json or text/plain; with */* the default is
text/html, and a client that sends no Accept header at all gets text/plain. The JSON form is a small
object with message, code and title.
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPConflict, HTTPNotFound
from webtest import TestApp
def missing(request):
raise HTTPNotFound("No shelf 9")
def clash(request):
# `body` replaces the generated page entirely
raise HTTPConflict(body="name taken", content_type="text/plain")
def custom_json(status, body, title, environ):
return {"status": status, "title": title, "path": environ["PATH_INFO"]}
def custom(request):
raise HTTPNotFound(json_formatter=custom_json)
with Configurator() as config:
for name, view in (("missing", missing), ("clash", clash), ("custom", custom)):
config.add_route(name, f"/{name}")
config.add_view(view, route_name=name)
client = TestApp(config.make_wsgi_app())
html = client.get("/missing", headers={"Accept": "text/html"}, status=404)
print(html.content_type)
print(client.get("/missing", headers={"Accept": "application/json"}, status=404).json)
print(client.get("/missing", status=404).text.splitlines()[0])
print(client.get("/clash", status=409).text)
print(client.get("/custom", headers={"Accept": "application/json"}, status=404).json)
The constructor arguments shared by every HTTP exception class are worth knowing because they control that body.
| Argument | Effect |
|---|---|
|
A plain-text message appended to the generated body. Escaped in the HTML form. |
|
A list of header pairs or a dict added to the response. Other |
|
Extra text that is normally hidden from end users (an HTML comment in the HTML form). |
|
A string template that frames the explanation and detail in the HTML and text forms. A custom template also receives the WSGI environment and the response headers as substitution values. |
|
A callable |
|
Only for the 3xx redirection classes that have a |
Source: pyramid.httpexceptions and What’s New in Pyramid 1.7
Two pitfalls follow from "both a response and an exception".
-
A returned
HTTPNotFoundorHTTPForbiddenis only a response. It does not enter the exception machinery, so the Not Found View and the Forbidden View are not invoked. The official documentation carries an explicit warning about both. To get your custom view,raisethe exception. -
A bare
raise HTTPFound(...)inside a view is turned into the redirect by the default exception response view; nothing else needs registering for it to work.
Exception Views
Writing raise HTTPNotFound() everywhere is fine for HTTP errors, but applications also have their own failure
vocabulary: ShelfFull, DuplicateShelf, InvalidIsbn. Pyramid lets you map any exception class to a view. The
official guide describes the mechanism: register a view configuration which matches the exception (or a subclass of
it), point it at a view callable, and the exception is passed as the context argument to the view and to any view
predicate registered with it.
from pyramid.config import Configurator
from pyramid.response import Response
from webtest import TestApp
class BookshelfError(Exception):
status = 500
def __init__(self, message):
super().__init__(message)
self.message = message
class ShelfFull(BookshelfError):
status = 409
def add_book(request):
raise ShelfFull("shelf 1 holds at most 2 books")
def crash(request):
raise BookshelfError("unexpected")
def bookshelf_error(exc, request):
# the exception arrives as `context`; with a (request) signature it is request.exception
request.response.status = exc.status
return {"error": type(exc).__name__, "message": exc.message}
def shelf_full_get(request):
return Response("full (GET)", status=409)
with Configurator() as config:
config.add_route("add", "/add")
config.add_route("crash", "/crash")
config.add_view(add_book, route_name="add")
config.add_view(crash, route_name="crash")
config.add_exception_view(bookshelf_error, context=BookshelfError, renderer="json")
# same family, but only for GET: more predicates make a view more specific
config.add_exception_view(shelf_full_get, context=ShelfFull, request_method="GET")
client = TestApp(config.make_wsgi_app())
print(client.get("/add", status=409).text)
print(client.post("/add", status=409).json)
print(client.get("/crash", status=500).json)
Source: Pyramid: Views (Custom Exception Views) and pyramid.view
The first call prints full (GET) because the GET-only view is more specific for that request; the POST and the crash
fall back to the generic BookshelfError view. A view like bookshelf_error may take (exc, request) or only
(request), like any view (see the alternate calling conventions in the official views guide); inside it,
request.exception is the exception object and request.exc_info is the sys.exc_info() triple.
The declarative form is @exception_view_config, found by config.scan(). It takes the exception as its first
positional argument and otherwise accepts the arguments of @view_config, for example renderer, route_name and the
predicates. It needs a real, importable module for the scan to find, so this block is shown without being run by the
page’s test harness (the same decorators were run from a module in the verification script).
# norun (decorators are collected by config.scan() from an importable module)
from pyramid.view import exception_view_config, forbidden_view_config, notfound_view_config
@exception_view_config(BookshelfError, renderer="json")
def bookshelf_error(exc, request):
request.response.status = exc.status
return {"error": type(exc).__name__, "message": exc.message}
@notfound_view_config(append_slash=True)
def not_found(request): ...
@forbidden_view_config()
def forbidden(request): ...
Source: Pyramid: Views (Custom Exception Views) and pyramid.view
How the right view is chosen
The lookup uses the same specificity rules as normal views, and the official guide spells out the consequences.
-
The view registered for the most specific class in the exception’s method resolution order wins; a view for
ShelfFullbeats one forBookshelfError, which beats one forException. -
Among views for the same class, the one that satisfies the most predicates wins (
route_name,request_method,accept,path_info,header,request_param, and predicates from add-ons such astm_active). -
The
namepredicate cannot be used: the name used to look up an exception view is always the empty string, so a named exception view is ignored.add_exception_viewactively refusesname,for_,exception_only,require_csrfandpermission. -
An exception raised by a custom root factory, a custom traverser, or a custom view or route predicate is caught and handled by exception views too.
add_exception_view versus add_view(context=SomeException)
add_exception_view(view, context=None, **view_options) registers with context=Exception by default (so it handles
almost everything), forces exception_only=True and sets the permission to "no permission required".
The exception_only flag, added in Pyramid 1.8, says that the view must only be considered while an exception is being
handled, never for a normal request whose traversal context happens to be an instance of that class.
Registering an exception class with plain config.add_view(context=SomeException) (the pre-1.8 way) still works, but
exception_only then defaults to False, and the view is registered twice: once as a normal view and once as an
exception view. The official guide recommends add_exception_view, and states that the view derivers wrapping the two
registrations may behave differently. For instance, the built-in csrf_view and secured_view derivers do not perform
security checks on exception views unless told to. Since Pyramid 1.7 a global permission set with
set_default_permission() no longer applies to exception views either, so an error page can be shown to an anonymous
user.
Pitfalls
Three mistakes account for most "my exception view does nothing" reports.
-
The status stays 200. A
Responsedefaults to200 OK. Unless the exception view setsrequest.response.status(or builds aResponse(..., status=...)), the client receives an error page with a success code, and monitoring sees nothing wrong. -
An exception inside an exception view is not handled again. It propagates out of the tween, to the server (or to
pyramid_tm, which aborts). Keep exception views small and free of database access. -
Setting
pyramid.tweensby hand drops the excview tween. If the configuration lists tweens explicitly, the official guide sayspyramid.tweens.excview_tween_factorymust be in the list, or no exception views run at all.
from pyramid.config import Configurator
from pyramid.response import Response
from webtest import TestApp
class Boom(Exception):
pass
def boom(request):
raise Boom()
def lazy_error_view(request):
return Response("oops") # no status: 200 OK
def careless_error_view(request):
raise ValueError("an error view that fails")
with Configurator() as config:
config.add_route("boom", "/boom")
config.add_view(boom, route_name="boom")
config.add_exception_view(lazy_error_view, context=Boom)
client = TestApp(config.make_wsgi_app())
print(client.get("/boom").status) # 200 OK: the status was never set
with Configurator() as config:
config.add_route("boom", "/boom")
config.add_view(boom, route_name="boom")
config.add_exception_view(careless_error_view, context=Boom)
client = TestApp(config.make_wsgi_app())
try:
client.get("/boom")
except ValueError as error:
print("escaped:", error)
Source: Pyramid: View Configuration (exception_only) and Pyramid: Using Hooks (Exception Views and View Derivers)
The Not Found View
Pyramid raises HTTPNotFound when it cannot map a URL to view code: no route matched, no view was registered for the
matched context and name, or a view was found but none of its predicates accepted the request (the exception is then
the subclass pyramid.exceptions.PredicateMismatch, still a 404). The response is produced by the Not Found View,
which you replace with config.add_notfound_view() or @notfound_view_config.
It is an exception view with context=HTTPNotFound fixed, so the same rules apply, with a few conveniences. It accepts
the arguments of add_view, except that it refuses name, permission, require_csrf, context, for_ and
exception_only, which make no sense here. The callable receives the request, or (context, request) where the context
is the HTTPNotFound instance (the traversal context stays available as request.context). request.exception is
that instance, and request.exception.message explains why Pyramid raised it.
The extra argument is append_slash. When it is true and the URL does not match but the same URL with a trailing slash
would, the view redirects to the slash-appended URL. If the argument is not a boolean but an HTTP exception class, that
class is used for the redirect, for example append_slash=HTTPMovedPermanently for a permanent 301. Since Pyramid 1.10
the default redirect class is HTTPTemporaryRedirect (307, which keeps the request method) instead of HTTPFound.
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
from pyramid.response import Response
from webtest import TestApp
def list_shelves(request):
return Response("shelves")
def post_only(request):
return Response("posted")
def not_found(request):
return Response(f"No such page: {request.path}", status=404)
def not_found_post(request):
return Response("No such POST target", status=404)
with Configurator() as config:
config.add_route("shelves", "/shelves/")
config.add_route("post", "/post")
config.add_view(list_shelves, route_name="shelves")
config.add_view(post_only, route_name="post", request_method="POST")
# default redirect class (307)
config.add_notfound_view(not_found, append_slash=True, request_method="GET")
# predicates narrow a Not Found View exactly as they narrow a view
config.add_notfound_view(not_found_post, request_method="POST")
client = TestApp(config.make_wsgi_app())
print(client.get("/shelves", status=307).location)
print(client.get("/nothing", status=404).text)
print(client.get("/post", status=404).text) # GET hits a POST-only view: predicate mismatch
print(client.post("/elsewhere", status=404).text)
with Configurator() as config:
config.add_route("shelves", "/shelves/")
config.add_view(list_shelves, route_name="shelves")
config.add_notfound_view(append_slash=HTTPMovedPermanently) # the view argument is optional
client = TestApp(config.make_wsgi_app())
print(client.get("/shelves", status=301).location)
The third request shows a pitfall: a route that exists but whose only view demands POST answers GET with a 404, not
with 405 Method Not Allowed. If you want a 405, register a second, predicate-free view for the same route that raises
HTTPMethodNotAllowed; the view with the POST predicate still wins for POST requests. The default Not Found response leaks nothing sensitive in production:
the message is only the request path.
Source: Pyramid: Using Hooks (Changing the Not Found View) and Pyramid: URL Dispatch (Redirecting to Slash-Appended Routes)
debug_notfound
When a 404 is unexpected, the usual cause is a configuration mistake: a forgotten config.scan(), a view registered for
another route, a typo in a route_name. The pyramid.debug_notfound setting (environment variable
PYRAMID_DEBUG_NOTFOUND, and also switched on by pyramid.debug_all) makes Pyramid describe why no view was found:
the URL, the path_info, the context, the view name, the subpath and the traversal details are written to the debug
logger and included in the message of the exception, so the default 404 page shows them too. Predicate mismatches
describe themselves even without it ("predicate mismatch for view …").
from pyramid.config import Configurator
from webtest import TestApp
for settings in ({}, {"pyramid.debug_notfound": "true"}):
with Configurator(settings=settings) as config:
client = TestApp(config.make_wsgi_app())
last_line = client.get("/missing", status=404).text.strip().splitlines()[-1]
print(last_line[:70])
The first line printed is only /missing; the second begins with debug_notfound of url http://localhost/missing. These
are diagnostics for developers: never switch debug_notfound (or debug_all) on in production, because the message
travels into the response body and exposes the application’s internals.
Source: Pyramid: View Configuration (NotFound Errors) and Pyramid: Environment Variables and .ini File Settings
The Forbidden View
When a view has a permission and the security policy’s permits() answers Denied, Pyramid raises HTTPForbidden;
the response comes from the Forbidden View, configured with config.add_forbidden_view() or @forbidden_view_config.
The default is a plain 403. The rules are the same as for the Not Found View (it refuses name, permission,
require_csrf, context, for_ and exception_only and accepts the other add_view arguments), and the callable
receives the request or (context, request), with the HTTPForbidden instance as the context. On request.exception,
message explains the failure and result carries the security policy’s Denied object, whose message is
more detailed when pyramid.debug_authorization is on.
The classical use is a redirect for anonymous visitors and a 403 page for logged-in ones. The example uses a tiny security policy only to trigger the check; policies, identities and permissions are the subject of the security pages of this section (authentication and authorization each have their own page).
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
from pyramid.security import Allowed, Denied
from webtest import TestApp
class HeaderPolicy:
"""Demo policy: the user is whatever the X-User header says. Never do this in production."""
def identity(self, request):
return request.headers.get("X-User")
def authenticated_userid(self, request):
return self.identity(request)
def permits(self, request, context, permission):
if self.identity(request) == "ada":
return Allowed("ada may do anything")
return Denied(f"{permission!r} needs the owner")
def remember(self, request, userid, **kw):
return []
def forget(self, request, **kw):
return []
def edit_shelf(request):
return Response("editing")
def login(request):
return Response("login page")
def forbidden(request):
if request.authenticated_userid is None:
return HTTPFound(request.route_url("login", _query={"next": request.path}))
return Response(f"Denied: {request.exception.result.msg}", status=403)
with Configurator() as config:
config.set_security_policy(HeaderPolicy())
config.add_route("edit", "/shelves/1/edit")
config.add_route("login", "/login")
config.add_view(edit_shelf, route_name="edit", permission="edit")
config.add_view(login, route_name="login")
config.add_forbidden_view(forbidden)
client = TestApp(config.make_wsgi_app())
print(client.get("/shelves/1/edit", status=302).location)
print(client.get("/shelves/1/edit", headers={"X-User": "grace"}, status=403).text)
print(client.get("/shelves/1/edit", headers={"X-User": "ada"}).text)
A frequent confusion is 401 versus 403. An unauthenticated request that hits a protected view and receives a redirect
to a login page is a browser flow; an API should instead answer 401 Unauthorized (with a WWW-Authenticate header
where the scheme requires one) for an anonymous caller and 403 Forbidden for an authenticated one, which the next
section shows.
Source: Pyramid: Using Hooks (Changing the Forbidden View) and Pyramid: Security
JSON Error Responses for an API
A JSON API should never answer an error with an HTML page. Pyramid gives two tools for this, and they combine.
The first tool is the content negotiation of HTTP exceptions shown earlier: a client that sends Accept:
application/json already gets a JSON body from any HTTPException, with the default message, code and title
fields. It is cheap, but the shape is fixed and it only applies to HTTP exceptions, not to a KeyError or a database
error.
The second tool is a set of exception views restricted to the API part of the site with a predicate. The
path_info predicate (a regular expression) is the natural choice for the Not Found View, because an unmatched URL has
no route, so route_name cannot be used. The example answers every failure under /api/ with a problem-details object
(the problem+json media type of RFC 9457) and keeps the HTML-side pages untouched.
import logging
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPBadRequest, HTTPError, HTTPFound
from pyramid.response import Response
from webtest import TestApp
log = logging.getLogger("bookshelf")
def problem(request, status, title, detail=None):
request.response.status = status
request.response.content_type = "application/problem+json"
return {
"type": "about:blank",
"title": title,
"status": status,
"detail": detail,
"instance": request.path,
}
def api_shelf(request):
if request.matchdict["id"] == "0":
raise HTTPBadRequest("id must be positive")
raise RuntimeError("database exploded at /var/db") # an internal detail, must not leak
def api_move(request):
raise HTTPFound("/api/shelves/1")
def html_home(request):
raise RuntimeError("html side")
# HTTPError covers 4xx and 5xx; HTTPException would also catch redirects such as HTTPFound
def api_http_error(exc, request):
return problem(request, exc.code, exc.title, exc.detail)
def api_not_found(request):
return problem(request, 404, "Not Found", "No such API resource")
def api_crash(exc, request):
log.exception("unhandled error on %s", request.path) # details go to the log only
return problem(request, 500, "Internal Server Error")
def html_crash(exc, request):
return Response("Something went wrong", status=500)
with Configurator() as config:
config.add_route("api_shelf", "/api/shelves/{id}")
config.add_route("api_move", "/api/move")
config.add_route("home", "/")
config.add_view(api_shelf, route_name="api_shelf", renderer="json")
config.add_view(api_move, route_name="api_move")
config.add_view(html_home, route_name="home")
config.add_exception_view(api_http_error, context=HTTPError, path_info=r"^/api/", renderer="json")
config.add_notfound_view(api_not_found, path_info=r"^/api/", renderer="json")
config.add_exception_view(api_crash, path_info=r"^/api/", renderer="json")
config.add_exception_view(html_crash)
client = TestApp(config.make_wsgi_app())
logging.disable(logging.CRITICAL) # keep the demo output short; the traceback goes to the logger
for path, status in (("/api/shelves/0", 400), ("/api/shelves/13", 500), ("/api/nothing", 404), ("/", 500)):
response = client.get(path, status=status)
print(response.status_int, response.content_type, response.text.strip()[:90])
print(client.get("/api/move", status=302).location)
Four design points are visible in the example.
-
The catch-all
api_crashview is registered forException(the default context ofadd_exception_view), is more specific than the HTML catch-all because of itspath_infopredicate, and logs the traceback withlog.exceptionwhile sending the client a generic message. Never putstr(exc)of an unexpected exception in a response: it can contain SQL, file paths or credentials. -
HTTPErroris the context of the HTTP-error view, notHTTPException. WithHTTPExceptionthe view would also catch a raisedHTTPFound, and the redirect would turn into a JSON body with status 302. -
The Not Found View is declared separately because it is more specific than the
HTTPErrorview for 404 and because Pyramid itself raises that exception. -
Setting the status on
request.responseworks with a renderer: the JSON renderer writes into that response object, so the status and content type set byproblem()are kept. Renderers are covered in Renderers & JSON.
Status codes that a client can act on are the real contract of an API. A pragmatic mapping for Bookshelf Shelves is
400 for malformed input, 401 for a missing or invalid credential, 403 for an authenticated but unauthorized caller, 404
for a missing shelf, 409 for a duplicate, 422 for a failed validation, 429 for rate limiting and 500 for everything
unexpected. An alternative to the path_info predicate is to let the client choose with accept="application/json"
on the exception view, so that the same URL answers HTML to browsers and JSON to API clients.
Source: Pyramid: Using Hooks and Pyramid: View Configuration (Predicate Arguments)
Exception Views and pyramid_tm
pyramid_tm wraps each request in one transaction and is the standard way to commit or abort database work in
Pyramid (it is covered fully in the SQLAlchemy page of this section). Its tween is positioned over the excview tween,
which has three consequences that determine what an exception view may do.
-
A raised exception is turned into a response by the exception view inside the transaction.
pyramid_tmthen sees that the request has anexc_info, and (with notm.commit_vetoconfigured) aborts the transaction instead of committing it, although the request ended with a response and no exception. A raisedHTTPBadRequesttherefore rolls back the work done before it. -
Whether the transaction is still open while the exception view runs is not guaranteed: an error raised by a tween that is itself over
pyramid_tm, or one rendered by the execution policy, is handled after the transaction ended. An exception view that reads the database (for example to look up the authenticated user) should declaretm_active=True, a view predicate registered bypyramid_tm; a fallback view without the predicate then covers the other cases. -
The transaction belongs to the failing request, so work done in it during an error is aborted with the rest. Writing an error report to the database from an exception view must use a separate connection or transaction (possibly in autocommit mode), never
request.dbsession.
The example uses a tiny recording data manager in place of a database, so that it can show how each transaction ended.
It also shows the sharpest edge: a returned HTTP error is a normal response, not a squashed exception, so it commits
unless a commit veto says otherwise. pyramid_tm.default_commit_veto, enabled with tm.commit_veto, aborts on
any 4xx or 5xx response (and on an X-Tm: abort response header).
import transaction
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPBadRequest
from pyramid.response import Response
from transaction.interfaces import IDataManager, TransientError
from webtest import TestApp
from zope.interface import implementer
EVENTS = []
@implementer(IDataManager)
class RecordingDataManager:
"""A stand-in for a database connection that records how the transaction ended."""
transaction_manager = transaction.manager
def __init__(self, label):
self.label = label
def abort(self, txn):
EVENTS.append(f"{self.label}: abort")
def tpc_begin(self, txn): ...
def commit(self, txn): ...
def tpc_vote(self, txn): ...
def tpc_finish(self, txn):
EVENTS.append(f"{self.label}: commit")
def tpc_abort(self, txn):
EVENTS.append(f"{self.label}: abort")
def sortKey(self):
return self.label
def saved(request):
request.tm.get().join(RecordingDataManager("saved"))
return Response("saved")
def raised(request):
request.tm.get().join(RecordingDataManager("raised"))
raise HTTPBadRequest("rejected")
def returned(request):
request.tm.get().join(RecordingDataManager("returned"))
return HTTPBadRequest("rejected")
def while_open(request):
EVENTS.append("exception view ran inside the transaction")
return Response("bad", status=400)
def build(settings=None):
with Configurator(settings=settings) as config:
config.include("pyramid_tm")
for name, view in (("saved", saved), ("raised", raised), ("returned", returned)):
config.add_route(name, f"/{name}")
config.add_view(view, route_name=name)
config.add_exception_view(while_open, context=HTTPBadRequest, tm_active=True)
return TestApp(config.make_wsgi_app())
client = build()
client.get("/saved")
client.get("/raised", status=400)
client.get("/returned", status=400)
print(*EVENTS, sep="\n")
EVENTS.clear()
client = build({"tm.commit_veto": "pyramid_tm.default_commit_veto"})
client.get("/returned", status=400)
print(*EVENTS, sep="\n")
Source: pyramid_tm documentation (Error Handling, Retries) and pyramid_retry documentation (View Predicates)
The first run prints saved: commit, then exception view ran inside the transaction and raised: abort, and finally
returned: commit: the data of a request that answered 400 was committed. The second run, with the veto, prints
returned: abort.
pyramid_retry adds a second wrinkle. With retry.attempts greater than 1, a request that fails with a retryable error
(any TransientError, or an exception marked with pyramid_retry.mark_error_retryable) is executed again on a fresh
request object. A response that an exception view rendered for a retryable error is discarded when another attempt
follows, so the view effectively runs once per attempt. The last_retry_attempt and retryable_error view predicates,
registered by pyramid_retry, let you give the user a "busy, try again later" response only after the final attempt.
Use last_retry_attempt; the exception view example in the official documentation spells the predicate differently,
but the registered name is last_retry_attempt.
from pyramid.config import Configurator
from pyramid.response import Response
from transaction.interfaces import TransientError
from webtest import TestApp
ATTEMPTS = []
def flaky(request):
ATTEMPTS.append(request.environ["retry.attempt"])
raise TransientError("serialization failure")
def give_up(request):
return Response("busy, try again later", status=503)
with Configurator(settings={"retry.attempts": "3"}) as config:
config.include("pyramid_retry")
config.include("pyramid_tm")
config.add_route("flaky", "/flaky")
config.add_view(flaky, route_name="flaky")
config.add_exception_view(give_up, context=TransientError, last_retry_attempt=True)
client = TestApp(config.make_wsgi_app())
response = client.get("/flaky", status=503)
print(ATTEMPTS, response.text)
The output is [0, 1, 2] busy, try again later: three attempts, and the 503 produced only when the third failed.
| Setting | Effect |
|---|---|
|
Dotted name of a function |
|
Dotted name of a function |
|
When true (the default), the transaction records the authenticated user id, which requires the security policy to be usable inside the transaction. |
|
Dotted name of a function returning the transaction manager to use instead of the thread-local one. |
|
How many times |
Source: pyramid_tm documentation (Error Handling, Retries) and pyramid_retry documentation (View Predicates)
Logging and Reporting
An exception view that swallows an exception also swallows the traceback: the WSGI server never sees it, so it is not
logged unless the view logs it. The catch-all view is therefore the right place for log.exception(...) (inside the
except-like context of the view, request.exc_info still holds the exception) and for handing the exception to an
error tracker. Client errors raised as HTTP exceptions are expected events, not bugs: log them at a low level, if at all.
The logging page of this section covers handlers and formats.
Two interactions are easy to miss. First, pyramid_debugtoolbar places its exception-handling tween over the excview
tween, so a catch-all exception view hides the interactive traceback in development; scope the catch-all with a
predicate or register it only when the debug toolbar is not included. Second, because an exception view renders a
normal response, outer middleware (a WSGI error reporter such as Sentry’s) never sees the exception unless the exception
view re-raises it or reports it itself.
request.invoke_exception_view(exc_info=None, request=None, secure=True, reraise=False) renders an exception through
the registered exception views from application code, for example in a custom tween placed over EXCVIEW or in an
execution policy. It returns the response; if no view handles the exception, HTTPNotFound is raised, or the original
exception when reraise=True. Since Pyramid 1.9 request.exception and request.exc_info reflect the exception that
produced the response.
import sys
from pyramid.config import Configurator
from pyramid.response import Response
from webtest import TestApp
class Boom(Exception):
pass
def start(request):
try:
raise Boom()
except Boom:
# render through the registered exception views, from application code
return request.invoke_exception_view(sys.exc_info(), reraise=True)
def nice(request):
return Response(f"handled {type(request.exception).__name__}", status=418)
with Configurator() as config:
config.add_route("start", "/start")
config.add_view(start, route_name="start")
config.add_exception_view(nice, context=Boom)
client = TestApp(config.make_wsgi_app())
print(client.get("/start", status=418).text)
Source: pyramid.request (invoke_exception_view) and Pyramid: Logging
Testing Error Handling
Error paths deserve the same tests as success paths, and Pyramid makes both kinds easy.
A unit test calls the view with pyramid.testing.DummyRequest and checks that the right HTTP exception is raised: HTTP
exceptions are plain Python exceptions, so pytest.raises works. This tests the decision to fail, not the response.
An integration test through webtest.TestApp tests the exception view as well: WebTest raises an AppError for any
status that is not 2xx or 3xx unless the test passes the expected status= (or expect_errors=True), which turns each
assertion into one that also checks the status code. Because the whole pipeline runs, you also see predicate effects
such as path_info or accept without extra work.
import pytest
from pyramid import testing
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPNotFound
from webtest import TestApp
from webtest.app import AppError
def show_shelf(request):
raise HTTPNotFound("no shelf")
# unit test: the view raises, no exception view is involved
with pytest.raises(HTTPNotFound):
show_shelf(testing.DummyRequest())
# integration test: the exception view's response and status are checked
with Configurator() as config:
config.add_route("shelf", "/shelves/1")
config.add_view(show_shelf, route_name="shelf")
client = TestApp(config.make_wsgi_app())
client.get("/shelves/1", status=404) # passes: the 404 is the expectation
try:
client.get("/shelves/1") # an unexpected 404 fails the test
except AppError as error:
print("unexpected status:", str(error).splitlines()[0][:50])
A pytest suite for the Bookshelf Shelves application can then combine the two with a fixture that builds the app once; the testing page of this section covers the fixtures in depth. A useful extra check is a test that requests a URL which cannot exist and asserts that the body contains nothing internal (no path of the server, no SQL, no traceback).
Pitfalls and Security
| Pitfall | What to do |
|---|---|
The error view returns a |
Set |
|
Raise it when the Not Found, Forbidden or your own exception views must apply; a returned exception is only a response. |
A catch-all view for |
Use |
|
Log it, answer with a generic message and an error identifier. Details leak SQL, paths and credentials. |
|
They write internal information into responses or expose a console. Keep them in the development |
An exception view that hits the database. |
Declare |
A returned 4xx response with |
It commits. Raise the exception or enable |
Retryable errors hidden by a catch-all. |
Remember that |
The Not Found View on |
Give the API its own |
Distinguishing 404 from 403 to hide the existence of a resource. |
Answer 404 for both "missing" and "not yours" when the existence of a shelf is itself confidential. |
What Changed and Where Other Material Is Outdated
| Version | Change |
|---|---|
1.7 |
HTTP exceptions negotiate |
1.8 |
|
1.9 |
|
1.10 |
|
2.0 |
The security policy API replaces the authentication and authorization policies, so a |
2.1 |
|
Tutorials written for Pyramid 1.x register the Not Found View with @view_config(context=HTTPNotFound) or with
@notfound_view_config() combined with a request.exception check, and use config.add_view(context=SomeError) for
domain errors. All of those still work, but add_exception_view and @exception_view_config are the current API since
1.8, and a forbidden view that depends on request.effective_principals must be revisited when moving to the security
policy of 2.0 (see the migration page of this section).