Change record status: 
Project: 
Introduced in branch: 
8.5.x
Introduced in version: 
8.5.0
Description: 

TL;DR

The following results in a 403 response that cannot be cached by Page Cache, Dynamic Page Cache or Varnish, because the exception carries no cacheability metadata:

if (!$config->get('exposed')) {
  throw new AccessDeniedHttpException('It is not safe for humans to configure this!');
}

In Drupal 8.5.0, we added Cacheable* variants of every HTTP exception class that Symfony provides. Which means we can now do for example:

if (!$config->get('exposed')) {
  throw new CacheableAccessDeniedHttpException($config, 'It is not safe for humans to configure this!');
}

The latter 403 exception carries cacheability metadata (the cacheability metadata of the config object) and therefore it can be converted into a cacheable response (which will be invalidated as soon as the configuration changes).

Goal

For certain use cases, having cacheable 4xx responses makes sense.

For example:

  1. a site providing anonymous users access to a REST API (like drupal.org!) may have quite a few 404 and 403 responses. For content that was deleted and unpublished, respectively.
  2. a site providing authenticated users access to a REST API, where content is moderated and 403 responses are quite common for content that has been unpublished for further review.

Rather than requiring those requests to generate the same responses over and over, every single time, we could cache them. It'd result in faster responses, and lower server load. They could then even be cached by Dynamic Page Cache (for authenticated users) and Page Cache (for anonymous users).

Problem

The problem is that in Drupal 8, we throw Symfony's HttpException (and its subclasses: NotFoundHttpException etc) exception objects in our controllers, to indicate that we want a certain 4xx response to be sent (for example a 404). Those classes don't carry cacheability metadata, only a message. Therefore it's unsafe to cache those 4xx responses, because it's unknown:

  1. how long it can be cached (cache max-age)
  2. what data it depends on, and so if that data changes, the 4xx response should be invalidated (cache tags)
  3. what the different variants might be (user role, user permissions, URL …), or whether everybody should be getting this 4xx response (cache contexts)

Solution

This is why we added \Drupal\Core\Cache\Exception\CacheableHttpExceptionInterface, which decorates Symfony's interface with cacheability metadata:

interface CacheableHttpExceptionInterface extends HttpExceptionInterface {
  …
}

A subclass of every Symfony exception class is provided:

  1. \Drupal\Core\Cache\Exception\CacheableAccessDeniedHttpException
  2. \Drupal\Core\Cache\Exception\CacheableBadRequestHttpException
  3. \Drupal\Core\Cache\Exception\CacheableConflictHttpException
  4. \Drupal\Core\Cache\Exception\CacheableGoneHttpException
  5. \Drupal\Core\Cache\Exception\CacheableHttpException
  6. \Drupal\Core\Cache\Exception\CacheableLengthRequiredHttpException
  7. \Drupal\Core\Cache\Exception\CacheableMethodNotAllowedHttpException
  8. \Drupal\Core\Cache\Exception\CacheableNotAcceptableHttpException
  9. \Drupal\Core\Cache\Exception\CacheableNotFoundHttpException
  10. \Drupal\Core\Cache\Exception\CacheablePreconditionFailedHttpException
  11. \Drupal\Core\Cache\Exception\CacheablePreconditionRequiredHttpException
  12. \Drupal\Core\Cache\Exception\CacheableServiceUnavailableHttpException
  13. \Drupal\Core\Cache\Exception\CacheableTooManyRequestsHttpException
  14. \Drupal\Core\Cache\Exception\CacheableUnauthorizedHttpException
  15. \Drupal\Core\Cache\Exception\CacheableUnprocessableEntityHttpException
  16. \Drupal\Core\Cache\Exception\CacheableUnsupportedMediaTypeHttpException

How to use

Any code that is updated to use these "richer" versions of the existing exceptions, will have cacheable responses. In other words: they can opt in. It's fine to continue to use Symfony's "bare" exceptions, it'll just mean the responses won't be cacheable.

Before
     throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'view'));
After
     $exception = new CacheableAccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'view'));
     $exception->addCacheableDependency($entity_access);
     throw $exception;
Impacts: 
Module developers