Add support for Cache-Control: only-if-cached and corresponding options for request() and send()
diff --git a/HISTORY.md b/HISTORY.md
index d496e03..8e5b086 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -4,10 +4,12 @@
 [See all issues and PRs for 0.10](https://github.com/reclosedev/requests-cache/milestone/5?closed=1)
 
 **Expiration & Headers:**
-* Add `refresh` option to `CachedSession.request()` and `send()` to make (and cache) an new request regardless of existing cache contents
-* Add `revalidate` option to `CachedSession.request()` and `send()` to send conditional request (if possible) before using a cached response
+* Add support for `Cache-Control: only-if-cached`
 * Revalidate for `Cache-Control: no-cache` request or response header
 * Revalidate for `Cache-Control: max-age=0, must-revalidate` response headers
+* Add `only_if_cached` option to `CachedSession.request()` and `send()` to return only cached results without sending real requests
+* Add `refresh` option to `CachedSession.request()` and `send()` to make (and cache) an new request regardless of existing cache contents
+* Add `revalidate` option to `CachedSession.request()` and `send()` to send conditional request (if possible) before using a cached response
 
 ### 0.9.3 (2022-02-22)
 * Fix handling BSON serializer differences between pymongo's `bson` and standalone `bson` codec.
diff --git a/docs/user_guide/expiration.md b/docs/user_guide/expiration.md
index ef0e6b3..5c81ef0 100644
--- a/docs/user_guide/expiration.md
+++ b/docs/user_guide/expiration.md
@@ -117,3 +117,57 @@
 ```python
 >>> session.remove_expired_responses(expire_after=timedelta(days=30))
 ```
+
+## Request Options
+In addition to the base arguments for {py:func}`requests.request`, requests-cache adds some extra
+cache-related arguments. These apply to {py:meth}`.CachedSession.request`,
+{py:meth}`.CachedSession.send`, and all HTTP method-specific functions (`get()`, `post()`, etc.).
+
+### Per-Request Expiration
+The `expire_after` argument can be used to override the session's expiration for a single request.
+```python
+>>> session = CachedSession(expire_after=300)
+>>> # This request will be cached for 60 seconds, not 300
+>>> session.get('http://httpbin.org/get', expire_after=60)
+```
+
+### Manual Refresh
+If you want to manually refresh a response before it expires, you can use the `refresh` argument.
+This will always send a new request, and ignore and overwrite any previously cached response. The
+response will be saved with a new expiration time, according to the normal expiration rules described above.
+```python
+>>> response = session.get('http://httpbin.org/get')
+>>> response = session.get('http://httpbin.org/get', refresh=True)
+>>> assert response.from_cache is False
+```
+
+A related argument is `revalidate`, which is basically a "soft refresh." It will send a quick
+{ref}`conditional request <conditional-requests>` to the server, and use the cached response if the
+remote content has not changed. If the cached response does not contain validation headers, this
+option will have no effect.
+
+### Cache-Only Requests
+If you want to only use cached responses without making any real requests, you can use the
+`only_if_cached` option. This essentially uses your cache in "offline mode". If a response isn't
+cached or is expired, you will get a `504 Not Cached` response instead.
+```python
+>>> session = CachedSession()
+>>> session.cache.clear()
+>>> response = session.get('http://httpbin.org/get', only_if_cached=True)
+>>> print(response.status_code)
+504
+>>> response.raise_for_status()
+HTTPError: 504 Server Error: Not Cached for url: http://httpbin.org/get
+```
+
+You can also combine this with `stale_if_error` to return cached responses even if they are expired.
+```python
+>>> session = CachedSession(expire_after=1, stale_if_error=True)
+>>> session.get('http://httpbin.org/get')
+>>> time.sleep(1)
+
+>>> # The response will be cached but expired by this point
+>>> response = session.get('http://httpbin.org/get', only_if_cached=True)
+>>> print(response.status_code)
+200
+```
diff --git a/docs/user_guide/headers.md b/docs/user_guide/headers.md
index ecc95c1..1dccde6 100644
--- a/docs/user_guide/headers.md
+++ b/docs/user_guide/headers.md
@@ -11,6 +11,7 @@
 would like to see, please create an issue to request it.
 ```
 
+(conditional-requests)=
 ## Conditional Requests
 [Conditional requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) are
 automatically sent for any servers that support them. Once a cached response expires, it will only
@@ -49,6 +50,8 @@
 - `Cache-Control: max-age`: Used as the expiration time in seconds
 - `Cache-Control: no-cache`: Revalidate with the server before using a cached response
 - `Cache-Control: no-store`: Skip reading from and writing to the cache
+- `Cache-Control: only-if-cached`: Only return results from the cache. If not cached, return a 504
+  response instead of sending a new request. Note that this may return a stale response.
 - `If-None-Match`: Automatically added for revalidation, if an `ETag` is available
 - `If-Modified-Since`: Automatically added for revalidation, if `Last-Modified` is available
 
diff --git a/requests_cache/cache_control.py b/requests_cache/cache_control.py
index 10e9ed1..278ed29 100644
--- a/requests_cache/cache_control.py
+++ b/requests_cache/cache_control.py
@@ -1,5 +1,6 @@
 """Internal utilities for determining cache expiration and other cache actions. This module defines
-the caching policy, and resulting actions are applied in the :py:mod:`requests_cache.session` module.
+the majority of the caching policy, and resulting actions are handled in
+:py:meth:`CachedSession.send`.
 
 .. automodsumm:: requests_cache.cache_control
    :classes-only:
@@ -65,6 +66,7 @@
     cache_control: bool = field(default=False)
     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)
     revalidate: bool = field(default=False)
     skip_read: bool = field(default=False)
@@ -80,6 +82,7 @@
         session_expire_after: ExpirationTime = None,
         urls_expire_after: ExpirationPatterns = None,
         request_expire_after: ExpirationTime = None,
+        only_if_cached: bool = False,
         refresh: bool = False,
         revalidate: bool = False,
         **kwargs,
@@ -107,17 +110,22 @@
         )
 
         # Check conditions for cache read and write based on args and request headers
-        check_expiration = directives.get('max-age') if cache_control else expire_after
         refresh_temp_header = request.headers.pop('requests-cache-refresh', False)
+        check_expiration = directives.get('max-age') if cache_control else expire_after
         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)
 
         return cls(
             cache_control=cache_control,
             cache_key=cache_key,
             expire_after=expire_after,
+            only_if_cached=only_if_cached,
             request_directives=directives,
-            revalidate=revalidate or 'no-cache' in directives,
+            revalidate=revalidate,
             skip_read=skip_read,
             skip_write=skip_write,
         )
@@ -232,6 +240,17 @@
     return kv_directives
 
 
+def get_504_response(request: PreparedRequest) -> Response:
+    from .models import CachedResponse
+
+    return CachedResponse(
+        url=request.url or '',
+        status_code=504,
+        reason='Not Cached',
+        request=request,  # type: ignore
+    )
+
+
 def get_url_expiration(
     url: Optional[str], urls_expire_after: ExpirationPatterns = None
 ) -> ExpirationTime:
diff --git a/requests_cache/session.py b/requests_cache/session.py
index 6292bbb..f86307c 100644
--- a/requests_cache/session.py
+++ b/requests_cache/session.py
@@ -25,7 +25,13 @@
 
 from ._utils import get_valid_kwargs
 from .backends import BackendSpecifier, init_backend
-from .cache_control import CacheActions, ExpirationTime, append_directive, get_expiration_seconds
+from .cache_control import (
+    CacheActions,
+    ExpirationTime,
+    append_directive,
+    get_504_response,
+    get_expiration_seconds,
+)
 from .models import AnyResponse, CachedResponse, set_response_defaults
 
 __all__ = ['ALL_METHODS', 'CachedSession', 'CacheMixin']
@@ -78,8 +84,9 @@
         method: str,
         url: str,
         *args,
-        expire_after: ExpirationTime = None,
         headers: MutableMapping[str, str] = None,
+        expire_after: ExpirationTime = None,
+        only_if_cached: bool = False,
         refresh: bool = False,
         revalidate: bool = False,
         **kwargs,
@@ -95,6 +102,8 @@
             expire_after: Expiration time to set only for this request; see details below.
                 Overrides ``CachedSession.expire_after``. Accepts all the same values as
                 ``CachedSession.expire_after``. Use ``-1`` to disable expiration.
+            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")
 
@@ -115,6 +124,8 @@
         headers = headers or {}
         if expire_after is not None:
             headers = append_directive(headers, f'max-age={get_expiration_seconds(expire_after)}')
+        if only_if_cached:
+            headers = append_directive(headers, 'only-if-cached')
         if revalidate:
             headers = append_directive(headers, 'no-cache')
         if refresh:
@@ -128,6 +139,7 @@
         self,
         request: PreparedRequest,
         expire_after: ExpirationTime = None,
+        only_if_cached: bool = False,
         refresh: bool = False,
         revalidate: bool = False,
         **kwargs,
@@ -137,6 +149,8 @@
 
         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")
         """
@@ -149,6 +163,7 @@
             session_expire_after=self.expire_after,
             urls_expire_after=self.urls_expire_after,
             cache_control=self.cache_control,
+            only_if_cached=only_if_cached,
             refresh=refresh,
             revalidate=revalidate,
             **kwargs,
@@ -161,9 +176,13 @@
             actions.update_from_cached_response(cached_response)
         is_expired = getattr(cached_response, 'is_expired', False)
 
-        # If the response is expired or missing, or the cache is disabled, then fetch a new response
-        if cached_response is None or actions.revalidate:
+        # 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.stale_if_error and actions.only_if_cached:
+            response = cached_response
         elif is_expired and self.stale_if_error:
             response = self._resend_and_ignore(request, actions, cached_response, **kwargs)
         elif is_expired:
diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py
index 1a33779..b7246d1 100644
--- a/tests/unit/test_session.py
+++ b/tests/unit/test_session.py
@@ -10,7 +10,7 @@
 
 import pytest
 import requests
-from requests import Request, RequestException
+from requests import HTTPError, Request, RequestException
 from requests.structures import CaseInsensitiveDict
 
 from requests_cache import ALL_METHODS, CachedSession
@@ -28,32 +28,14 @@
     MOCKED_URL_REDIRECT_TARGET,
 )
 
-
-def test_init_unregistered_backend():
-    with pytest.raises(ValueError):
-        CachedSession(backend='nonexistent')
-
-
-def test_cache_path_expansion():
-    session = CachedSession('~', backend='filesystem')
-    assert session.cache.cache_dir == Path("~").expanduser()
-
-
-@patch.dict(BACKEND_CLASSES, {'mongo': get_placeholder_class()})
-def test_init_missing_backend_dependency():
-    """Test that the correct error is thrown when a user does not have a dependency installed"""
-    with pytest.raises(ImportError):
-        CachedSession(backend='mongo')
+# Basic initialization
+# -----------------------------------------------------
 
 
 class MyCache(BaseCache):
     pass
 
 
-# Basic initialization
-# -----------------------------------------------------
-
-
 def test_init_backend_instance():
     backend = MyCache()
     session = CachedSession(backend=backend)
@@ -80,6 +62,23 @@
     assert session.cache.cache_name == 'test_cache'
 
 
+def test_init_unregistered_backend():
+    with pytest.raises(ValueError):
+        CachedSession(backend='nonexistent')
+
+
+def test_init_cache_path_expansion():
+    session = CachedSession('~', backend='filesystem')
+    assert session.cache.cache_dir == Path("~").expanduser()
+
+
+@patch.dict(BACKEND_CLASSES, {'mongo': get_placeholder_class()})
+def test_init_missing_backend_dependency():
+    """Test that the correct error is thrown when a user does not have a dependency installed"""
+    with pytest.raises(ImportError):
+        CachedSession(backend='mongo')
+
+
 def test_repr(mock_session):
     """Test session and cache string representations"""
     mock_session.expire_after = 11
@@ -534,10 +533,10 @@
     mock_session.mock_adapter.register_uri(
         'GET', unexpired_url, status_code=200, text='mock response'
     )
-    mock_session.expire_after = timedelta(seconds=0.2)
+    mock_session.expire_after = 1
     mock_session.get(MOCKED_URL)
     mock_session.get(MOCKED_URL_JSON)
-    time.sleep(0.2)
+    time.sleep(1)
     mock_session.get(unexpired_url)
 
     # At this point we should have 1 unexpired response and 2 expired responses
@@ -548,7 +547,7 @@
     assert cached_response.url == unexpired_url
 
     # Now the last response should be expired as well
-    time.sleep(0.2)
+    time.sleep(1)
     mock_session.remove_expired_responses()
     assert len(mock_session.cache.responses) == 0
 
@@ -657,6 +656,51 @@
     assert response.from_cache is False
 
 
+def test_request_only_if_cached__cached(mock_session):
+    """only_if_cached has no effect if the response is already cached"""
+    mock_session.get(MOCKED_URL)
+    response = mock_session.get(MOCKED_URL, only_if_cached=True)
+    assert response.from_cache is True
+    assert response.is_expired is False
+
+
+def test_request_only_if_cached__uncached(mock_session):
+    """only_if_cached should return a 504 response if it is not already cached"""
+    response = mock_session.get(MOCKED_URL, only_if_cached=True)
+    assert response.status_code == 504
+    with pytest.raises(HTTPError):
+        response.raise_for_status()
+
+
+def test_request_only_if_cached__expired(mock_session):
+    """By default, only_if_cached will not return an expired response"""
+    mock_session.get(MOCKED_URL, expire_after=1)
+    time.sleep(1)
+
+    response = mock_session.get(MOCKED_URL, only_if_cached=True)
+    assert response.status_code == 504
+
+
+def test_request_only_if_cached__stale_if_error__expired(mock_session):
+    """only_if_cached *will* return an expired response if stale_if_error is also set"""
+    mock_session.get(MOCKED_URL, expire_after=1)
+    mock_session.stale_if_error = True
+    time.sleep(1)
+
+    response = mock_session.get(MOCKED_URL, only_if_cached=True)
+    assert response.from_cache is True
+    assert response.is_expired is False
+
+
+def test_request_only_if_cached__prepared_request(mock_session):
+    """The only_if_cached option should also work for PreparedRequests with CachedSession.send()"""
+    request = Request(method='GET', url=MOCKED_URL, headers={}).prepare()
+    response = mock_session.send(request, only_if_cached=True)
+    assert response.status_code == 504
+    with pytest.raises(HTTPError):
+        response.raise_for_status()
+
+
 def test_request_refresh(mock_session):
     """The refresh option should send and cache a new request. Any expire_after value provided
     should overwrite the previous value."""