Refactor request-level settings into separate RequestSettings class
diff --git a/requests_cache/cache_control.py b/requests_cache/cache_control.py
index 9f155f8..10a1de8 100644
--- a/requests_cache/cache_control.py
+++ b/requests_cache/cache_control.py
@@ -27,7 +27,7 @@
 
 __all__ = ['DO_NOT_CACHE', 'CacheActions']
 if TYPE_CHECKING:
-    from .models import CachedResponse, CacheSettings
+    from .models import CachedResponse, CacheSettings, RequestSettings
 
 # May be set by either headers or expire_after param to disable caching or disable expiration
 DO_NOT_CACHE = 0
@@ -42,32 +42,19 @@
 
 @define
 class CacheActions:
-    """A class that translates cache settings and headers into specific actions to take for a
-    given cache item. Actions include:
+    """Translates cache settings and headers into specific actions to take for a given cache item.
 
-    * Read from the cache
-    * Write to the cache
-    * Revalidate cache item (if it exists)
-    * Set cache expiration
-    * Add headers for conditional requests
-
-    If multiple sources provide an expiration time, they will be used in the following order of
-    precedence:
-
-    1. Cache-Control request headers
-    2. Cache-Control response headers (if enabled)
-    3. Per-request expiration
-    4. Per-URL expiration
-    5. Per-session expiration
-
-    See :ref:`headers` for more details about behavior.
+    * See :ref:`precedence` for behavior if multiple sources provide an expiration
+    * See :ref:`headers` for more details about header behavior
     """
 
     cache_key: str = field(default=None)
     expire_after: ExpirationTime = field(default=None)
-    only_if_cached: bool = field(default=False)
     request_directives: Dict[str, CacheDirective] = field(factory=dict)
+    resend_request: bool = field(default=False)
     revalidate: bool = field(default=False)
+    error_504: bool = field(default=False)
+    send_request: bool = field(default=False)
     settings: CacheSettings = field(default=None)
     skip_read: bool = field(default=False)
     skip_write: bool = field(default=False)
@@ -77,12 +64,8 @@
     def from_request(
         cls,
         cache_key: str,
-        settings: 'CacheSettings',
         request: PreparedRequest,
-        request_expire_after: ExpirationTime = None,
-        only_if_cached: bool = False,
-        refresh: bool = False,
-        revalidate: bool = False,
+        settings: 'RequestSettings',
         **kwargs,
     ):
         """Initialize from request info and cache settings.
@@ -102,7 +85,7 @@
         # Check expiration values in order of precedence
         expire_after = coalesce(
             directives.get('max-age'),
-            request_expire_after,
+            settings.request_expire_after,
             get_url_expiration(request.url, settings.urls_expire_after),
             settings.expire_after,
         )
@@ -113,14 +96,15 @@
         skip_write = check_expiration == DO_NOT_CACHE or 'no-store' in directives
 
         # These behaviors may be set by either request headers or keyword arguments
-        only_if_cached = only_if_cached or 'only-if-cached' in directives
-        revalidate = revalidate or 'no-cache' in directives
-        skip_read = skip_write or refresh or bool(refresh_temp_header)
+        settings.only_if_cached = settings.only_if_cached or 'only-if-cached' in directives
+        revalidate = settings.revalidate or 'no-cache' in directives
+        skip_read = any(
+            [settings.refresh, skip_write, bool(refresh_temp_header), settings.disabled]
+        )
 
         return cls(
             cache_key=cache_key,
             expire_after=expire_after,
-            only_if_cached=only_if_cached,
             request_directives=directives,
             revalidate=revalidate,
             skip_read=skip_read,
@@ -139,7 +123,16 @@
 
         Used after fetching a cached response, but before potentially sending a new request.
         """
-        if not response:
+        # Determine if we need to send a new request or respond with an error
+        is_expired = getattr(response, 'is_expired', False)
+        if self.settings.only_if_cached and (response is None or is_expired):
+            self.error_504 = True
+        elif response is None:
+            self.send_request = True
+        elif is_expired and not (self.settings.only_if_cached and self.settings.stale_if_error):
+            self.resend_request = True
+
+        if response is None:
             return
 
         # Revalidation may be triggered by either stale response or request/cached response headers
@@ -154,16 +147,13 @@
         )
 
         if self.revalidate:
+            self.send_request = True
             if response.headers.get('ETag'):
                 self.validation_headers['If-None-Match'] = response.headers['ETag']
             if response.headers.get('Last-Modified'):
                 self.validation_headers['If-Modified-Since'] = response.headers['Last-Modified']
 
-    def update_from_response(
-        self,
-        response: Response,
-        cache_disabled: bool = False,
-    ):
+    def update_from_response(self, response: Response):
         """Update expiration + actions based on headers from a new response.
 
         Used after receiving a new response but before saving it to the cache.
@@ -191,9 +181,9 @@
         # Apply filter callback, if any
         filtered_out = self.settings.filter_fn is not None and not self.settings.filter_fn(response)
 
-        # Perform remaining checks needed to determine if the given response should be cached
+        # Apply and log remaining checks needed to determine if the response should be cached
         cache_criteria = {
-            'disabled cache': cache_disabled,
+            'disabled cache': self.settings.disabled,
             'disabled method': str(response.request.method) not in self.settings.allowable_methods,
             'disabled status': response.status_code not in self.settings.allowable_codes,
             'disabled by filter': filtered_out,
@@ -202,6 +192,12 @@
         logger.debug(f'Pre-cache checks for response from {response.url}: {cache_criteria}')
         self.skip_write = any(cache_criteria.values())
 
+    def update_request(self, request: PreparedRequest) -> PreparedRequest:
+        """Apply validation headers (if any) before sending a request"""
+        # if self.revalidate:
+        request.headers.update(self.validation_headers)
+        return request
+
     def update_revalidated_response(
         self, response: Response, cached_response: CachedResponse
     ) -> CachedResponse:
diff --git a/requests_cache/models/__init__.py b/requests_cache/models/__init__.py
index 497ca3f..1931ea2 100644
--- a/requests_cache/models/__init__.py
+++ b/requests_cache/models/__init__.py
@@ -7,7 +7,7 @@
 from .raw_response import CachedHTTPResponse
 from .request import CachedRequest
 from .response import CachedResponse, set_response_defaults
-from .settings import CacheSettings
+from .settings import CacheSettings, RequestSettings
 
 AnyResponse = Union[Response, CachedResponse]
 AnyRequest = Union[Request, PreparedRequest, CachedRequest]
diff --git a/requests_cache/models/settings.py b/requests_cache/models/settings.py
index 01ad937..b32df3d 100644
--- a/requests_cache/models/settings.py
+++ b/requests_cache/models/settings.py
@@ -1,6 +1,6 @@
 from typing import TYPE_CHECKING, Callable, Dict, Iterable, Union
 
-from attr import define, field
+from attr import asdict, define, field
 
 from .._utils import get_valid_kwargs
 from ..cache_control import ExpirationTime
@@ -13,14 +13,24 @@
 KeyCallback = Callable[..., str]
 
 
+# TODO: RequestSettings subclass?
+#   This would work by merging settings for an individual request on top of session settings
+#   This would also allow for *any* session setting to be passed as a per-request setting
+#   ...But only for send(), not request()
+# TODO: HeaderSettings class?
+#   This would encapsulate settings from Cache-Control directives and other cache headers
+#   Downside: that logic would then live in this moduel instead of in cache_control (which may be a better fit)
+
+
 @define(init=False)
 class CacheSettings:
-    """Class to store cache settings used by :py:class:`.CachedSession` and backends.
+    """Settings that affect caching behavior, used by :py:class:`.CachedSession` and
+    :py:class:`.BaseCache`.
 
     Args:
         allowable_codes: Only cache responses with one of these status codes
         allowable_methods: Cache only responses for one of these HTTP methods
-        cache_control: Use Cache-Control headers to set expiration
+        cache_control: Use Cache-Control and other response headers to set expiration
         expire_after: Time after which cached items will expire
         filter_fn: Response filtering function that indicates whether or not a given response should
             be cached.
@@ -35,12 +45,13 @@
     allowable_codes: Iterable[int] = field(default=(200,))
     allowable_methods: Iterable[str] = field(default=('GET', 'HEAD'))
     cache_control: bool = field(default=False)
-    # cache_disabled: bool = field(default=False)
+    disabled: bool = field(default=False)
     expire_after: ExpirationTime = field(default=-1)
     filter_fn: FilterCallback = field(default=None)
     ignored_parameters: Iterable[str] = field(default=None)
     key_fn: KeyCallback = field(default=None)
     match_headers: Union[Iterable[str], bool] = field(default=False)
+    only_if_cached: bool = field(default=False)
     stale_if_error: bool = field(default=False)
     urls_expire_after: Dict[str, ExpirationTime] = field(factory=dict)
 
@@ -54,3 +65,18 @@
         # Ignore invalid kwargs for easier initialization from mixed **kwargs
         kwargs = get_valid_kwargs(self.__attrs_init__, kwargs)
         self.__attrs_init__(**kwargs)
+
+
+@define(init=False)
+class RequestSettings(CacheSettings):
+    """Cache settings that may be set for an individual request"""
+
+    refresh: bool = field(default=False)
+    revalidate: bool = field(default=False)
+    request_expire_after: ExpirationTime = field(default=-1)
+
+    def __init__(self, session_settings: CacheSettings, **kwargs):
+        # Start with session-level cache settings and add/override with request-level settings
+        kwargs['request_expire_after'] = kwargs.pop('expire_after', None)
+        kwargs = {**asdict(session_settings), **kwargs}
+        super().__init__(**kwargs)
diff --git a/requests_cache/session.py b/requests_cache/session.py
index b766330..218605d 100644
--- a/requests_cache/session.py
+++ b/requests_cache/session.py
@@ -32,7 +32,13 @@
     get_504_response,
     get_expiration_seconds,
 )
-from .models import AnyResponse, CachedResponse, CacheSettings, set_response_defaults
+from .models import (
+    AnyResponse,
+    CachedResponse,
+    CacheSettings,
+    RequestSettings,
+    set_response_defaults,
+)
 
 __all__ = ['ALL_METHODS', 'CachedSession', 'CacheMixin']
 ALL_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
@@ -45,6 +51,10 @@
     MIXIN_BASE = object
 
 
+# TODO: Better docs for __init__
+# TODO: Better function signatures (due to passing around **kwargs instead of explicit keyword args)
+
+
 class CacheMixin(MIXIN_BASE):
     """Mixin class that extends :py:class:`requests.Session` with caching features.
     See :py:class:`.CachedSession` for usage details.
@@ -54,17 +64,13 @@
         self,
         cache_name: str = 'http_cache',
         backend: BackendSpecifier = None,
-        settings: CacheSettings = None,
         **kwargs,
     ):
-        settings = settings or CacheSettings(**kwargs)
-        self.cache = init_backend(cache_name, backend, settings=settings, **kwargs)
-        self._disabled = False
+        self.cache = init_backend(cache_name, backend, **kwargs)
         self._lock = RLock()
 
-        # If the superclass is custom Session, pass along any valid kwargs
-        session_kwargs = get_valid_kwargs(super().__init__, kwargs)
-        super().__init__(**session_kwargs)  # type: ignore
+        # If the mixin superclass is custom Session, pass along any valid kwargs
+        super().__init__(**get_valid_kwargs(super().__init__, kwargs))  # type: ignore
 
     def request(  # type: ignore
         self,
@@ -122,65 +128,43 @@
         with patch_form_boundary(**kwargs):
             return super().request(method, url, *args, **kwargs)
 
-    def send(
-        self,
-        request: PreparedRequest,
-        expire_after: ExpirationTime = None,
-        only_if_cached: bool = False,
-        refresh: bool = False,
-        revalidate: bool = False,
-        **kwargs,
-    ) -> AnyResponse:
+    def send(self, request: PreparedRequest, **kwargs) -> AnyResponse:
         """Send a prepared request, with caching. See :py:meth:`.request` for notes on behavior, and
-        see :py:meth:`requests.Session.send` for parameters. Additional parameters:
-
-        Args:
-            expire_after: Expiration time to set only for this request
-            only_if_cached: Only return results from the cache. If not cached, return a 504 response
-                instead of sending a new request.
-            refresh: Always make a new request, and overwrite any previously cached response
-            revalidate: Revalidate with the server before using a cached response (e.g., a "soft refresh")
+        see :py:meth:`requests.Session.send` for parameters.
         """
-        # Determine which actions to take based on request info and cache settings
-        cache_key = self.cache.create_key(request, **kwargs)
+        # Determine which actions to take based on settings and request info
         actions = CacheActions.from_request(
-            cache_key=cache_key,
-            request=request,
-            request_expire_after=expire_after,
-            only_if_cached=only_if_cached,
-            refresh=refresh,
-            revalidate=revalidate,
-            settings=self.cache.settings,
+            self.cache.create_key(request, **kwargs),
+            request,
+            RequestSettings(self.settings, **kwargs),
             **kwargs,
         )
 
         # Attempt to fetch a cached response
         cached_response: Optional[CachedResponse] = None
-        if not (self._disabled or actions.skip_read):
-            cached_response = self.cache.get_response(cache_key)
+        if not actions.skip_read:
+            cached_response = self.cache.get_response(actions.cache_key)
             actions.update_from_cached_response(cached_response)
-        is_expired = getattr(cached_response, 'is_expired', False)
 
         # Handle missing and expired responses based on settings and headers
-        if (cached_response is None or is_expired) and actions.only_if_cached:
-            response: AnyResponse = get_504_response(request)
-        elif cached_response is None or actions.revalidate:
-            response = self._send_and_cache(request, actions, cached_response, **kwargs)
-        elif is_expired and self.cache.settings.stale_if_error and actions.only_if_cached:
-            response = cached_response
-        elif is_expired:
-            response = self._resend(request, actions, cached_response, **kwargs)
+        if actions.send_request:
+            response: AnyResponse = self._send_and_cache(
+                request, actions, cached_response, **kwargs
+            )
+        elif actions.resend_request:
+            response = self._resend(request, actions, cached_response, **kwargs)  # type: ignore
+        elif actions.error_504:
+            response = get_504_response(request)
         else:
-            response = cached_response
+            response = cached_response  # type: ignore  # Guaranteed to be non-None by this point
 
         # If the request has been filtered out and was previously cached, delete it
-        filter_fn = self.cache.settings.filter_fn
-        if filter_fn is not None and not filter_fn(response):
+        if self.settings.filter_fn is not None and not self.settings.filter_fn(response):
             logger.debug(f'Deleting filtered response for URL: {response.url}')
-            self.cache.delete(cache_key)
+            self.cache.delete(actions.cache_key)
             return response
 
-        # Dispatch any hooks here, because they are removed before pickling
+        # Dispatch any hooks here, because they are removed during serialization
         return dispatch_hook('response', request.hooks, response, **kwargs)
 
     def _send_and_cache(
@@ -190,19 +174,16 @@
         cached_response: CachedResponse = None,
         **kwargs,
     ) -> AnyResponse:
-        """Send the request and cache the response, unless disabled by settings or headers.
-
-        If applicable, also add headers to make a conditional request. If we get a 304 Not Modified
-        response, return the stale cache item.
+        """Send a request and cache the response, unless disabled by settings or headers.
+        If applicable, also handle conditional requests.
         """
-        if actions.revalidate:
-            request.headers.update(actions.validation_headers)
+        request = actions.update_request(request)
         response = super().send(request, **kwargs)
         actions.update_from_response(response)
 
         if not actions.skip_write:
             self.cache.save_response(response, actions.cache_key, actions.expires)
-        elif cached_response and response.status_code == 304:
+        elif cached_response is not None and response.status_code == 304:
             cached_response = actions.update_revalidated_response(response, cached_response)
             self.cache.save_response(cached_response, actions.cache_key, actions.expires)
             return cached_response
@@ -217,31 +198,31 @@
         cached_response: CachedResponse,
         **kwargs,
     ) -> AnyResponse:
-        """Attempt to resend the request and cache the new response."""
+        """Handle a stale cached response by attempting to resend the request and cache a fresh
+        response
+        """
         logger.debug('Stale response; attempting to re-send request')
         try:
-            # Attempt to send the request and cache the new response
             response = self._send_and_cache(request, actions, cached_response, **kwargs)
-            if self.cache.settings.stale_if_error:
+            if self.settings.stale_if_error:
                 response.raise_for_status()
             return response
         except Exception:
-            return self._handle_error(cached_response)
+            return self._handle_error(cached_response, actions)
 
-    def _handle_error(self, cached_response: CachedResponse) -> AnyResponse:
+    def _handle_error(self, cached_response: CachedResponse, actions: CacheActions) -> AnyResponse:
         """Handle a request error based on settings:
         * Default behavior: delete the stale cache item and re-raise the error
         * stale-if-error: Ignore the error and and return the stale cache item
         """
-        if self.cache.settings.stale_if_error:
+        if self.settings.stale_if_error:
             logger.warning(
                 f'Request for URL {cached_response.request.url} failed; using cached response',
                 exc_info=True,
             )
             return cached_response
         else:
-            # TODO: Ensure cache_key is always populated
-            self.cache.delete(cached_response.cache_key or '')
+            self.cache.delete(actions.cache_key)
             raise
 
     @contextmanager
@@ -258,14 +239,22 @@
             ...     s.get('http://httpbin.org/ip')
 
         """
-        if self._disabled:
+        if self.settings.disabled:
             yield
         else:
-            self._disabled = True
+            self.settings.disabled = True
             try:
                 yield
             finally:
-                self._disabled = False
+                self.settings.disabled = False
+
+    @property
+    def settings(self) -> CacheSettings:
+        return self.cache.settings
+
+    @settings.setter
+    def settings(self, value: CacheSettings):
+        self.cache.settings = value
 
     def remove_expired_responses(self, expire_after: ExpirationTime = None):
         """Remove expired responses from the cache, optionally with revalidation
@@ -276,25 +265,15 @@
         self.cache.remove_expired_responses(expire_after)
 
     def __repr__(self):
-        repr_attrs = [
-            'cache',
-            'expire_after',
-            'urls_expire_after',
-            'allowable_codes',
-            'allowable_methods',
-            'stale_if_error',
-            'cache_control',
-        ]
-        attr_strs = [f'{k}={repr(getattr(self, k))}' for k in repr_attrs]
-        return f'<CachedSession({", ".join(attr_strs)})>'
+        return f'<CachedSession(cache={self.cache}, settings={self.settings})>'
 
 
 class CachedSession(CacheMixin, OriginalSession):
     """Session class that extends :py:class:`requests.Session` with caching features.
 
-    See individual :py:mod:`backend classes <requests_cache.backends>` for additional backend-specific arguments.
-    Also see :ref:`user-guide` for more details and examples on how the following arguments
-    affect cache behavior.
+    See individual :py:mod:`backend classes <requests_cache.backends>` for additional
+    backend-specific arguments. Also see :ref:`user-guide` for more details and examples on how the
+    following arguments affect cache behavior.
 
     Args:
         cache_name: Cache prefix or namespace, depending on backend
@@ -302,6 +281,7 @@
             ``['sqlite', 'filesystem', 'mongodb', 'gridfs', 'redis', 'dynamodb', 'memory']``
         serializer: Serializer name or instance; name may be one of
             ``['pickle', 'json', 'yaml', 'bson']``.
+        kwargs: Additional keyword arguments for :py:class:`.CacheSettings` or the selected backend
     """
 
 
diff --git a/tests/unit/test_cache_control.py b/tests/unit/test_cache_control.py
index 3b1f8a4..c942891 100644
--- a/tests/unit/test_cache_control.py
+++ b/tests/unit/test_cache_control.py
@@ -10,7 +10,7 @@
     get_expiration_datetime,
     get_url_expiration,
 )
-from requests_cache.models.response import CachedResponse
+from requests_cache.models.response import CachedResponse, CacheSettings, RequestSettings
 from tests.conftest import ETAG, HTTPDATE_DATETIME, HTTPDATE_STR, LAST_MODIFIED
 
 IGNORED_DIRECTIVES = [
@@ -47,12 +47,12 @@
         request.headers = {'Cache-Control': f'max-age={request_expire_after}'}
     get_url_expiration.return_value = url_expire_after
 
+    settings = CacheSettings(cache_control=True, expire_after=1)
+    settings = RequestSettings(settings, expire_after=request_expire_after)
     actions = CacheActions.from_request(
         cache_key='key',
         request=request,
-        request_expire_after=request_expire_after,
-        session_expire_after=1,
-        cache_control=True,
+        settings=RequestSettings(settings, expire_after=request_expire_after),
     )
     assert actions.expire_after == expected_expiration
 
@@ -70,9 +70,8 @@
 )
 def test_init_from_headers(headers, expected_expiration):
     """Test with Cache-Control request headers"""
-    actions = CacheActions.from_request(
-        cache_key='key', cache_control=True, request=MagicMock(headers=headers)
-    )
+    settings = RequestSettings(cache_control=True)
+    actions = CacheActions.from_request('key', MagicMock(headers=headers), settings)
 
     assert actions.cache_key == 'key'
     if expected_expiration == DO_NOT_CACHE:
@@ -104,22 +103,20 @@
 )
 def test_init_from_settings(url, request_expire_after, expected_expiration):
     """Test with per-session, per-request, and per-URL expiration"""
-    urls_expire_after = {
-        '*.site_1.com': timedelta(hours=12),
-        'site_2.com/resource_1': timedelta(hours=20),
-        'site_2.com/resource_2': timedelta(days=7),
-        'site_2.com/static': -1,
-    }
+    settings = CacheSettings(
+        expire_after=1,
+        urls_expire_after={
+            '*.site_1.com': timedelta(hours=12),
+            'site_2.com/resource_1': timedelta(hours=20),
+            'site_2.com/resource_2': timedelta(days=7),
+            'site_2.com/static': -1,
+        },
+    )
     request = MagicMock(url=url)
     if request_expire_after:
         request.headers = {'Cache-Control': f'max-age={request_expire_after}'}
 
-    actions = CacheActions.from_request(
-        cache_key='key',
-        request=request,
-        session_expire_after=1,
-        urls_expire_after=urls_expire_after,
-    )
+    actions = CacheActions.from_request('key', request, RequestSettings(settings))
     assert actions.expire_after == expected_expiration
 
 
@@ -148,12 +145,8 @@
         headers=headers,
     )
 
-    actions = CacheActions.from_request(
-        cache_key='key',
-        cache_control=cache_control,
-        request=request,
-        session_expire_after=expire_after,
-    )
+    settings = CacheSettings(cache_control=cache_control, expire_after=expire_after)
+    actions = CacheActions.from_request('key', request, RequestSettings(settings))
     assert actions.expire_after == expected_expiration
     assert actions.skip_read == expected_skip_read
 
@@ -173,8 +166,9 @@
 def test_update_from_cached_response(response_headers, expected_validation_headers):
     """Conditional request headers should be added if the cached response is expired"""
     actions = CacheActions.from_request(
-        cache_key='key',
-        request=MagicMock(url='https://img.site.com/base/img.jpg', headers={}),
+        'key',
+        MagicMock(url='https://img.site.com/base/img.jpg', headers={}),
+        RequestSettings(),
     )
     cached_response = CachedResponse(
         headers=response_headers, expires=datetime.now() - timedelta(1)
@@ -197,8 +191,9 @@
     """Conditional request headers should be added if requested by headers (even if the response
     is not expired)"""
     actions = CacheActions.from_request(
-        cache_key='key',
-        request=MagicMock(url='https://img.site.com/base/img.jpg', headers=request_headers),
+        'key',
+        MagicMock(url='https://img.site.com/base/img.jpg', headers=request_headers),
+        RequestSettings(),
     )
     cached_response = CachedResponse(headers={'ETag': ETAG, **response_headers}, expires=None)
 
@@ -211,8 +206,9 @@
     """Conditional request headers should NOT be added if the cached response is not expired and
     revalidation is not requested by headers"""
     actions = CacheActions.from_request(
-        cache_key='key',
-        request=MagicMock(url='https://img.site.com/base/img.jpg', headers={}),
+        'key',
+        MagicMock(url='https://img.site.com/base/img.jpg', headers={}),
+        RequestSettings(),
     )
     cached_response = CachedResponse(
         headers={'ETag': ETAG, 'Last-Modified': LAST_MODIFIED}, expires=None
@@ -242,9 +238,7 @@
     """Test with Cache-Control response headers"""
     url = 'https://img.site.com/base/img.jpg'
     actions = CacheActions.from_request(
-        cache_key='key',
-        request=MagicMock(url=url),
-        cache_control=True,
+        'key', MagicMock(url=url), RequestSettings(cache_control=True)
     )
     actions.update_from_response(MagicMock(url=url, headers=headers))
 
@@ -259,7 +253,7 @@
 def test_update_from_response__ignored():
     url = 'https://img.site.com/base/img.jpg'
     actions = CacheActions.from_request(
-        cache_key='key', request=MagicMock(url=url), cache_control=False
+        'key', MagicMock(url=url), RequestSettings(cache_control=False)
     )
     actions.update_from_response(MagicMock(url=url, headers={'Cache-Control': 'max-age=5'}))
     assert actions.expire_after is None
@@ -275,7 +269,7 @@
     url = 'https://img.site.com/base/img.jpg'
     headers = {**cache_headers, **validator_headers}
     actions = CacheActions.from_request(
-        cache_key='key', request=MagicMock(url=url), cache_control=True
+        'key', MagicMock(url=url), RequestSettings(cache_control=True)
     )
     actions.update_from_response(MagicMock(url=url, headers=headers))
     assert actions.expires == mock_datetime.utcnow()
@@ -288,12 +282,9 @@
     request = PreparedRequest()
     request.url = 'https://img.site.com/base/img.jpg'
     request.headers = {'Cache-Control': directive}
-    actions = CacheActions.from_request(
-        cache_key='key',
-        request=request,
-        session_expire_after=1,
-        cache_control=True,
-    )
+    settings = CacheSettings(expire_after=1, cache_control=True)
+    actions = CacheActions.from_request('key', request, RequestSettings(settings))
+
     assert actions.expire_after == 1
     assert actions.revalidate is False
     assert actions.skip_read is False
diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py
index b7246d1..86d10cb 100644
--- a/tests/unit/test_session.py
+++ b/tests/unit/test_session.py
@@ -52,8 +52,8 @@
     )
 
     assert session.cache.cache_name == 'test_cache'
-    assert session.cache.ignored_parameters == ['foo']
-    assert session.cache.match_headers is True
+    assert session.cache.settings.ignored_parameters == ['foo']
+    assert session.cache.settings.match_headers is True
 
 
 def test_init_backend_class():
@@ -134,8 +134,8 @@
     """Test all relevant combinations of methods and data fields. Requests with different request
     params, data, or json should not be cached under different keys based on an ignored param.
     """
-    mock_session.cache.ignored_parameters = ['ignored']
-    mock_session.cache.match_headers = True
+    mock_session.cache.settings.ignored_parameters = ['ignored']
+    mock_session.cache.settings.match_headers = True
     params_1 = {'ignored': 'value_1', 'not_ignored': 'value_1'}
     params_2 = {'ignored': 'value_2', 'not_ignored': 'value_1'}
     params_3 = {'ignored': 'value_2', 'not_ignored': 'value_2'}
@@ -153,7 +153,7 @@
     """Test all relevant combinations of methods and data fields. Requests with ignored params
     should have those values redacted from the cached response.
     """
-    mock_session.cache.ignored_parameters = ['access_token']
+    mock_session.cache.settings.ignored_parameters = ['access_token']
     params_1 = {'access_token': 'asdf', 'not_ignored': 'value_1'}
 
     mock_session.request(method, MOCKED_URL, **{field: params_1})
@@ -296,7 +296,7 @@
 
 def test_match_headers(mock_session):
     """With match_headers, requests with different headers should have different cache keys"""
-    mock_session.cache.match_headers = True
+    mock_session.cache.settings.match_headers = True
     headers_list = [
         {'Accept': 'application/json'},
         {'Accept': 'text/xml'},
@@ -310,7 +310,7 @@
 
 def test_match_headers__normalize(mock_session):
     """With match_headers, the same headers (in any order) should have the same cache key"""
-    mock_session.cache.match_headers = True
+    mock_session.cache.settings.match_headers = True
     headers = {'Accept': 'application/json', 'Custom': 'abc'}
     reversed_headers = {'Custom': 'abc', 'Accept': 'application/json'}
     assert mock_session.get(MOCKED_URL, headers=headers).from_cache is False
@@ -319,7 +319,7 @@
 
 def test_match_headers__list(mock_session):
     """match_headers can optionally be a list of specific headers to include"""
-    mock_session.cache.match_headers = ['Accept']
+    mock_session.cache.settings.match_headers = ['Accept']
     headers_1 = {'Accept': 'application/json', 'User-Agent': 'qutebrowser'}
     headers_2 = {'Accept': 'application/json', 'User-Agent': 'Firefox'}
     headers_3 = {'Accept': 'text/plain', 'User-Agent': 'qutebrowser'}
@@ -333,7 +333,7 @@
 def test_include_get_headers():
     """include_get_headers is aliased to match_headers for backwards-compatibility"""
     session = CachedSession(include_get_headers=True, backend='memory')
-    assert session.cache.match_headers is True
+    assert session.cache.settings.match_headers is True
 
 
 # Error handling
@@ -351,8 +351,8 @@
 def test_expired_request_error(mock_session):
     """Without stale_if_error (default), if there is an error while re-fetching an expired
     response, the request should be re-raised and the expired item deleted"""
-    mock_session.stale_if_error = False
-    mock_session.expire_after = 1
+    mock_session.cache.settings.stale_if_error = False
+    mock_session.cache.settings.expire_after = 1
     mock_session.get(MOCKED_URL)
     time.sleep(1)
 
@@ -364,8 +364,8 @@
 
 def test_stale_if_error__exception(mock_session):
     """With stale_if_error, expect to get old cache data if there is an exception during a request"""
-    mock_session.stale_if_error = True
-    mock_session.expire_after = 1
+    mock_session.cache.settings.stale_if_error = True
+    mock_session.cache.settings.expire_after = 1
 
     assert mock_session.get(MOCKED_URL).from_cache is False
     assert mock_session.get(MOCKED_URL).from_cache is True
@@ -377,9 +377,9 @@
 
 def test_stale_if_error__error_code(mock_session):
     """With stale_if_error, expect to get old cache data if a response has an error status code"""
-    mock_session.stale_if_error = True
-    mock_session.expire_after = 1
-    mock_session.allowable_codes = (200, 404)
+    mock_session.cache.settings.stale_if_error = True
+    mock_session.cache.settings.expire_after = 1
+    mock_session.cache.settings.allowable_codes = (200, 404)
 
     assert mock_session.get(MOCKED_URL_404).from_cache is False
 
@@ -391,7 +391,7 @@
 def test_old_data_on_error():
     """stale_if_error is aliased to old_data_on_error for backwards-compatibility"""
     session = CachedSession(old_data_on_error=True, backend='memory')
-    assert session.stale_if_error is True
+    assert session.cache.settings.stale_if_error is True
 
 
 def test_cache_disabled(mock_session):
@@ -431,7 +431,7 @@
 
 
 def test_filter_fn(mock_session):
-    mock_session.filter_fn = lambda r: r.request.url != MOCKED_URL_JSON
+    mock_session.cache.settings.filter_fn = lambda r: r.request.url != MOCKED_URL_JSON
     mock_session.get(MOCKED_URL)
     mock_session.get(MOCKED_URL_JSON)
 
@@ -442,7 +442,7 @@
 def test_filter_fn__retroactive(mock_session):
     """filter_fn should also apply to previously cached responses"""
     mock_session.get(MOCKED_URL_JSON)
-    mock_session.filter_fn = lambda r: r.request.url != MOCKED_URL_JSON
+    mock_session.cache.settings.filter_fn = lambda r: r.request.url != MOCKED_URL_JSON
     mock_session.get(MOCKED_URL_JSON)
 
     assert not mock_session.cache.has_url(MOCKED_URL_JSON)
@@ -453,7 +453,7 @@
         """Create a key based on only the request URL (without params)"""
         return request.url.split('?')[0]
 
-    mock_session.cache.key_fn = create_key
+    mock_session.cache.settings.key_fn = create_key
     mock_session.get(MOCKED_URL)
     response = mock_session.get(MOCKED_URL, params={'k': 'v'})
     assert response.from_cache is True