Repository: stravalib/stravalib
Issue category: Feature Request

Issue:
ENH: built-in token refresh
### Feature Type

- [ ] Adding new functionality to stravalib

- [X] Changing existing functionality in stravalib

- [ ] Removing existing functionality in stravalib


### Problem Description

It would be nice if stravalib would automatically refresh access tokens whenever they expire.

### Feature Description

Calling a client method will automatically refresh an access token if it's expired (or about to expire).

### Alternative Solutions

Letting users handle token expiry themselves (which is the current situation)

### Additional Context

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Hints:
@jsamoocha i'd really love to work on this issue. I haven't created a decorator before but use them and want to better understand how they work. I was thinking I would make a decorator that runs on the [`protocol.ApiV3._request` helper method](https://github.com/stravalib/stravalib/blob/main/src/stravalib/protocol.py#L267)

I will start working on this, but please know that you can totally redirect, refactor, and redo any PRs that I submit! For me, this will be a really cool learning project. 
Okay, before I go any further, I want to think through handling the client_secret and ID. I'm worried about making that an attribute of our ApiV3 and client objects, so instead, I thought about a three-pronged approach.

1. Use a . file - python-dotenv will allow a user to create a .env file with both values, and access them when it tries to refresh. This will work well for users working locally who don't want to deal with environment variables. 
2. If python-dotenv is not installed, check the user's environment for client_secret and client_id. This will work well in any CI build and local system with users who can add environment variables. 
3. the user must refresh their token if the above attempts fail. We raise a message that says, "Your token has expired; please refresh it." 

Is there a better way or is this a sound approach?
If you modify `_request()`, there is no need for a decorator. But it may introduce some issues with recursive calls (i.e., `_request()` may need to call `refresh_access_token()`, which in turn calls `_request()`). You may need to build guards against potentially infinite recursion.

Your approach to dealing with the `client_secret` seems good, but please be aware I'm not the no1 security expert.

Patch:
diff --git a/.gitignore b/.gitignore
index ec39dfd0..2f555a67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,9 +21,10 @@ examples/strava-oauth/settings.cfg
 .nox
 .vscode/*
 docs/_build/*
+.env
 
 
-# so people don't mistakenly delete and submit a pr with these
+# So people don't mistakenly delete and submit a pr with these
 stravalib/tests/test.ini-example
 stravalib/tests/test.ini
 stravalib/docs/api
diff --git a/changelog.md b/changelog.md
index 3ddeba07..4eff92c5 100644
--- a/changelog.md
+++ b/changelog.md
@@ -5,7 +5,8 @@
 ### Added
 
 - Add: `SummaryAthlete` return to exchange_code_for_token (@lwasser, #552)
-- Add: Add more information about how our mock test fixture works (@lwasser, #292)
+- Add: Add more information about how our mock fixture works (@lwasser, #292)
+- Add(feature): Automatic token refresh :sparkles: built into stravalib (@lwasser, @jsamoocha, #585)
 
 ### Fixed
 
diff --git a/docs/contributing/development-guide.md b/docs/contributing/development-guide.md
index f60b6c8b..a9e6a441 100644
--- a/docs/contributing/development-guide.md
+++ b/docs/contributing/development-guide.md
@@ -219,7 +219,7 @@ force the branch to be in the same commit state as your local branch.
 
 ### Typing using mypy
 
-To ensure proper typing throughout our library we use [mypy](https://mypy.readthedocs.io/). To run `mypy` across Python versions use:
+We use [mypy](https://mypy.readthedocs.io/) to ensure proper typing throughout our library. To run `mypy` across Python versions, use:
 
 `nox -s mypy`
 
@@ -346,7 +346,6 @@ uploaded on the GitHub action run on `ubuntu` and `Python 3.11`. When that step
 actions completes, the report will be processed and returned to the pull request.
 ```
 
-
 ## Tests & the stravalib mock fixture
 
 To run integration tests that ensure stravalib is interacting with API data correctly, Stravalib uses a mock object accessed through a
diff --git a/pyproject.toml b/pyproject.toml
index 5e7d4690..5a6bbf3a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -62,7 +62,10 @@ build = ["build"]
 tests = [
   "pytest",
   "pytest-cov",
-  "responses"
+  # Run tests in parallel
+  "pytest-xdist",
+  "responses",
+  "pytest-mock"
 ]
 docs = [
   "sphinx",
diff --git a/src/stravalib/client.py b/src/stravalib/client.py
index 243b49d9..4c93ffd0 100644
--- a/src/stravalib/client.py
+++ b/src/stravalib/client.py
@@ -69,6 +69,8 @@ def __init__(
         rate_limit_requests: bool = True,
         rate_limiter: limiter.RateLimiter | None = None,
         requests_session: Session | None = None,
+        token_expires: int | None = None,
+        refresh_token: str | None = None,
     ) -> None:
         """
         Initialize a new client object.
@@ -86,6 +88,12 @@ def __init__(
             :class:`stravalib.util.limiter.DefaultRateLimiter` will be used.
         requests_session : requests.Session() object
             (Optional) pass request session object.
+        token_expires : int
+            epoch timestamp -- seconds since jan 1 1970 (Epoch timestamp)
+            This represents the date and time that the token will expire. It is
+            used to automatically check and refresh the token in the client
+            method on all API requests.
+        refresh_token : str
 
         """
         self.log = logging.getLogger(
@@ -103,6 +111,8 @@ def __init__(
 
         self.protocol = ApiV3(
             access_token=access_token,
+            refresh_token=refresh_token,
+            token_expires=token_expires,
             requests_session=requests_session,
             rate_limiter=rate_limiter,
         )
@@ -113,14 +123,13 @@ def access_token(self) -> str | None:
         return self.protocol.access_token
 
     @access_token.setter
-    def access_token(self, token_value: str) -> None:
+    def access_token(self, token_value: str | None) -> None:
         """Set the currently configured authorization token.
 
         Parameters
         ----------
-        token_value : int
-             User's access token for authentication.
-
+        access_token : str
+             User's access token
 
         Returns
         -------
@@ -128,6 +137,48 @@ def access_token(self, token_value: str) -> None:
         """
         self.protocol.access_token = token_value
 
+    @property
+    def token_expires(self) -> int | None:
+        """The currently configured authorization token."""
+        return self.protocol.token_expires
+
+    @token_expires.setter
+    def token_expires(self, expires_value: int) -> None:
+        """Used to set and update the refresh token.
+
+        Parameters
+        ----------
+        expires_value : int
+             Current token expiration time (epoch seconds)
+
+        Returns
+        -------
+
+        """
+        self.protocol.token_expires = expires_value
+
+    @property
+    def refresh_token(self) -> str | None:
+        """The currently configured authorization token."""
+        return self.protocol.refresh_token
+
+    @refresh_token.setter
+    def refresh_token(self, refresh_value: str) -> None:
+        """Used to set and update the refresh token.
+
+        Parameters
+        ----------
+        refresh_value : str
+             Current token refresh value.
+
+        Returns
+        -------
+        None
+            Updates the `refresh_token` attribute in the Client class.
+
+        """
+        self.protocol.refresh_token = refresh_value
+
     def authorization_url(
         self,
         client_id: int,
diff --git a/src/stravalib/protocol.py b/src/stravalib/protocol.py
index 25773496..b8efa313 100644
--- a/src/stravalib/protocol.py
+++ b/src/stravalib/protocol.py
@@ -8,6 +8,8 @@
 import abc
 import functools
 import logging
+import os
+import time
 from collections.abc import Callable
 from typing import TYPE_CHECKING, Any, Literal, TypedDict
 from urllib.parse import urlencode, urljoin, urlunsplit
@@ -64,6 +66,10 @@ def __init__(
         rate_limiter: (
             Callable[[dict[str, str], RequestMethod], None] | None
         ) = None,
+        token_expires: int | None = None,
+        refresh_token: str | None = None,
+        client_id: int | None = None,
+        client_secret: str | None = None,
     ):
         """Initialize this protocol client, optionally providing a (shared)
         :class:`requests.Session` object.
@@ -74,12 +80,27 @@ def __init__(
             The token that provides access to a specific Strava account.
         requests_session : :class:`requests.Session`
             An existing :class:`requests.Session` object to use.
-
+        rate_limiter : Callable[[dict[str, str], RequestMethod], None], optional
+            A callable used to enforce rate-limiting for API requests.
+            The callable accepts a dict of headers and the
+            HTTP request method as arguments. Defaults to None.
+        token_expires: int
+            Epoch time in seconds when the token expires
+        refresh_token: str
+            Refresh token used to re-authenticate with Strava
+        client_id: int
+            client id for the Strava APP pulled from the users envt
+        client_secret: str
+            client secret for the Strava app pulled from the users envt
         """
         self.log = logging.getLogger(
             "{0.__module__}.{0.__name__}".format(self.__class__)
         )
-        self.access_token = access_token
+        self.access_token: str | None = access_token
+        self.token_expires: int | None = token_expires
+        self.refresh_token: str | None = refresh_token
+        self.client_id: int | None = None
+        self.client_secret: str | None = None
         if requests_session:
             self.rsession: requests.Session = requests_session
         else:
@@ -88,6 +109,189 @@ def __init__(
         self.rate_limiter = rate_limiter or (
             lambda _request_params, _method: None
         )
+        # Check for credentials when initializing
+        self._check_credentials()
+
+    def _check_credentials(self) -> None:
+        """Gets Strava client_id and secret credentials from user's environment.
+
+        If the user environment is populated with both values and
+        client_id is a proper int, it returns a tuple with both values.
+        Otherwise it returns None.
+
+        Returns
+        -------
+        None
+            If the client_id and secret are available it populates self
+            with both variables to support automatic token refresh.
+        """
+        # Default both to None; set if they are available in the correct format
+        client_id: int | None = None
+        client_secret: str | None = None
+
+        client_id_str = os.environ.get("STRAVA_CLIENT_ID")
+        client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
+        # Make sure client_id exists and can be cast to int
+        if client_id_str:
+            try:
+                # Make sure client_id is a valid int
+                client_id = int(client_id_str)
+            except ValueError:
+                logging.error("STRAVA_CLIENT_ID must be a valid integer.")
+        else:
+            logging.error(
+                "Please make sure your STRAVA_CLIENT_ID is set in your environment."
+            )
+
+        if client_id and client_secret:
+            self.client_id = client_id
+            self.client_secret = client_secret
+        else:
+            logging.warning(
+                "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET not found in your "
+                " environment. Please refresh your access_token manually."
+                " Or add STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET to your environment."
+            )
+        return None
+
+    def _request(
+        self,
+        url: str,
+        params: dict[str, Any] | None = None,
+        files: dict[str, SupportsRead[str | bytes]] | None = None,
+        method: RequestMethod = "GET",
+        check_for_errors: bool = True,
+    ) -> Any:
+        """Perform the underlying request, returning the parsed JSON results.
+
+        Before running, this method will check to make sure that your
+        access_token is valid. If it isn't, it will refresh it and then make
+        the requested Strava API call.
+
+        Parameters
+        ----------
+        url : str
+            The request URL.
+        params : Dict[str,Any]
+            Request parameters
+        files : Dict[str,file]
+            Dictionary of file name to file-like objects.
+        method : str
+            The request method (GET/POST/etc.)
+        check_for_errors : bool
+            Whether to raise an error or not.
+
+        Returns
+        -------
+        Dict[str, Any]
+            The parsed JSON response.
+        """
+
+        # Only refresh token if we know the users' environment is setup
+        if "/oauth/token" not in url and self.client_id and self.client_secret:
+            self.refresh_expired_token()
+
+        url = self.resolve_url(url)
+        self.log.info(
+            "{method} {url!r} with params {params!r}".format(
+                method=method, url=url, params=params
+            )
+        )
+        if params is None:
+            params = {}
+        if self.access_token:
+            params["access_token"] = self.access_token
+
+        methods = {
+            "GET": self.rsession.get,
+            "POST": functools.partial(self.rsession.post, files=files),
+            "PUT": self.rsession.put,
+            "DELETE": self.rsession.delete,
+        }
+
+        try:
+            requester = methods[method.upper()]
+        except KeyError:
+            raise ValueError(
+                "Invalid/unsupported request method specified: {}".format(
+                    method
+                )
+            )
+
+        raw = requester(url, params=params)  # type: ignore[operator]
+        # Rate limits are taken from HTTP response headers
+        # https://developers.strava.com/docs/rate-limits/
+        self.rate_limiter(raw.headers, method)
+
+        if check_for_errors:
+            self._handle_protocol_error(raw)
+
+        # 204 = No content
+        if raw.status_code in [204]:
+            resp = {}
+        else:
+            resp = raw.json()
+
+        return resp
+
+    def _token_expired(self) -> bool:
+        """Checks if a token has expired or not. Returns True if it's expired.
+
+        Returns
+        -------
+        bool
+            True if token has expired, otherwise returns false
+        """
+        if self.token_expires:
+            if time.time() > self.token_expires:
+                print("Your token has expired; Refreshing it now.")
+                return True
+            else:
+                return False
+        else:
+            logging.warning(
+                "Please make sure you've set client.token_expires if you"
+                " want automatic token refresh to work"
+            )
+            return False
+
+    def refresh_expired_token(self) -> None:
+        """Checks to see if a token has expired and auto refreshes it
+        if the user has setup their environment with the client
+        secret information.
+
+        Returns
+        -------
+        None
+            If all is setup properly, updates and resets the access_token
+            attribute. Otherwise it logs a warning
+        """
+        if not self.refresh_token:
+            logging.warning(
+                "Please set client.refresh_token if you want to use"
+                "the auto token-refresh feature"
+            )
+            return
+
+        # Token is not yet expired, move on
+        if not self._token_expired():
+            return
+
+        # This should never be false, BUT mypy wants a reminder that these values
+        # are populated
+        assert self.client_id is not None, "client_id is required but is None."
+        assert (
+            self.client_secret is not None
+        ), "client_secret is required but is None."
+
+        # If the token is expired AND the refresh token exists
+        if self._token_expired() and self.refresh_token:
+            self.refresh_access_token(
+                client_id=self.client_id,
+                client_secret=self.client_secret,
+                refresh_token=self.refresh_token,
+            )
+            return
 
     def authorization_url(
         self,
@@ -217,7 +421,10 @@ def exchange_code_for_token(
             return access_info, None
 
     def refresh_access_token(
-        self, client_id: int, client_secret: str, refresh_token: str
+        self,
+        client_id: int,
+        client_secret: str,
+        refresh_token: str,
     ) -> AccessInfo:
         """Exchanges the previous refresh token for a short-lived access token
         and a new refresh token (used to obtain the next access token later on)
@@ -238,6 +445,12 @@ def refresh_access_token(
             Dictionary containing the access_token, refresh_token and
             expires_at (number of seconds since Epoch when the provided
             access token will expire)
+
+        Notes
+        -----
+        This method is user facing. Here, we don't populate client_id and
+        client_secret from self; A user can call this method and refresh the token manually with
+        those values.
         """
         response = self._request(
             f"https://{self.server}/oauth/token",
@@ -255,6 +468,9 @@ def refresh_access_token(
             "expires_at": response["expires_at"],
         }
         self.access_token = response["access_token"]
+        # Update expires_at and refresh to support automatic refresh
+        self.refresh_token = response["refresh_token"]
+        self.token_expires = response["expires_at"]
 
         return access_info
 
@@ -278,77 +494,6 @@ def resolve_url(self, url: str) -> str:
             )
         return url
 
-    def _request(
-        self,
-        url: str,
-        params: dict[str, Any] | None = None,
-        files: dict[str, SupportsRead[str | bytes]] | None = None,
-        method: RequestMethod = "GET",
-        check_for_errors: bool = True,
-    ) -> Any:
-        """Perform the underlying request, returning the parsed JSON results.
-
-        Parameters
-        ----------
-        url : str
-            The request URL.
-        params : Dict[str,Any]
-            Request parameters
-        files : Dict[str,file]
-            Dictionary of file name to file-like objects.
-        method : str
-            The request method (GET/POST/etc.)
-        check_for_errors : bool
-            Whether to raise
-
-        Returns
-        -------
-        Dict[str,Any]
-            The parsed JSON response.
-        """
-        url = self.resolve_url(url)
-        self.log.info(
-            "{method} {url!r} with params {params!r}".format(
-                method=method, url=url, params=params
-            )
-        )
-        if params is None:
-            params = {}
-        if self.access_token:
-            params["access_token"] = self.access_token
-
-        methods = {
-            "GET": self.rsession.get,
-            "POST": functools.partial(self.rsession.post, files=files),
-            "PUT": self.rsession.put,
-            "DELETE": self.rsession.delete,
-        }
-
-        try:
-            requester = methods[method.upper()]
-        except KeyError:
-            raise ValueError(
-                "Invalid/unsupported request method specified: {}".format(
-                    method
-                )
-            )
-
-        raw = requester(url, params=params)  # type: ignore[operator]
-        # Rate limits are taken from HTTP response headers
-        # https://developers.strava.com/docs/rate-limits/
-        self.rate_limiter(raw.headers, method)
-
-        if check_for_errors:
-            self._handle_protocol_error(raw)
-
-        # 204 = No content
-        if raw.status_code in [204]:
-            resp = {}
-        else:
-            resp = raw.json()
-
-        return resp
-
     def _handle_protocol_error(
         self, response: requests.Response
     ) -> requests.Response:


Test patch:
diff --git a/src/stravalib/tests/conftest.py b/src/stravalib/tests/conftest.py
index 063907b6..feb738a1 100644
--- a/src/stravalib/tests/conftest.py
+++ b/src/stravalib/tests/conftest.py
@@ -1,8 +1,11 @@
+import os
 import warnings
+from unittest.mock import patch
 
 import pytest
 
 from stravalib import Client
+from stravalib.protocol import ApiV3
 from stravalib.tests.integration.strava_api_stub import StravaAPIMock
 
 warnings.simplefilter("always")
@@ -18,3 +21,27 @@ def mock_strava_api():
 @pytest.fixture
 def client():
     return Client()
+
+
+@pytest.fixture
+def mock_strava_env():
+    """Fixture that mocks Strava environment variables."""
+    with patch.dict(
+        os.environ,
+        {"STRAVA_CLIENT_ID": "12345", "STRAVA_CLIENT_SECRET": "123ghp234"},
+        clear=True,  # Ensures other environment variables are not leaked
+    ):
+        yield
+
+
+@pytest.fixture
+def apiv3_instance(mock_strava_env):
+    """Fixture to create an ApiV3 instance for testing. Takes the
+    mock_strava_env which sets up environment variables"""
+    mock_strava_env
+    return ApiV3(
+        access_token="dummy_access_token",
+        client_id=123456,
+        client_secret="clientfoosecret",
+        refresh_token="refresh_value123",
+    )
diff --git a/src/stravalib/tests/unit/test_client.py b/src/stravalib/tests/unit/test_client.py
new file mode 100644
index 00000000..36a59869
--- /dev/null
+++ b/src/stravalib/tests/unit/test_client.py
@@ -0,0 +1,14 @@
+import stravalib
+
+
+def test_client_protocol_initialization():
+    """Test that instantiating Client sets corresponding values in protocol."""
+    client = stravalib.Client(
+        access_token="access_123",
+        token_expires=123456,
+        refresh_token="refresh_123",
+    )
+
+    assert client.protocol.access_token == "access_123"
+    assert client.protocol.token_expires == 123456
+    assert client.protocol.refresh_token == "refresh_123"
diff --git a/src/stravalib/tests/unit/test_protocol_ApiV3_token_refresh.py b/src/stravalib/tests/unit/test_protocol_ApiV3_token_refresh.py
new file mode 100644
index 00000000..820770c2
--- /dev/null
+++ b/src/stravalib/tests/unit/test_protocol_ApiV3_token_refresh.py
@@ -0,0 +1,191 @@
+import logging
+import os
+import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+import stravalib
+
+###### _request method responsible for API call ######
+
+
+@pytest.mark.parametrize(
+    "url, should_call_refresh",
+    [
+        ("/oauth/token", False),  # Skips refreshing for /oauth/token
+        (
+            "/api/some_other_endpoint",
+            True,
+        ),
+    ],
+)
+def test_request_skips_refresh_for_oauth_token(
+    apiv3_instance, url, should_call_refresh
+):
+    """Test that _request skips refresh_expired_token when provided with
+    a url that includes /oauth/token to avoid recursion"""
+
+    apiv3_instance.refresh_expired_token = MagicMock()
+    apiv3_instance.resolve_url = MagicMock(return_value="/oauth/token")
+    apiv3_instance.rsession.get = MagicMock(
+        return_value=MagicMock(status_code=204, headers={})
+    )
+
+    apiv3_instance._request(url)
+
+    if should_call_refresh:
+        apiv3_instance.refresh_expired_token.assert_called_once()
+    else:
+        apiv3_instance.refresh_expired_token.assert_not_called()
+
+
+###### _check_credentials helper ######
+
+
+class TestCheckCredentials:
+    """Tests for the `_check_credentials` app setup helper method."""
+
+    @pytest.fixture(autouse=True)
+    def setup(self, apiv3_instance):
+        """Shared setup for all tests in this class."""
+        self.apiv3 = apiv3_instance
+
+    def test_check_credentials_success(self):
+        """Ensure that if credentials exist in the environment and client_id can
+        be cast to a valid int that the method populates self. client ID and
+        secret as expected."""
+
+        self.apiv3._check_credentials()
+        assert isinstance(self.apiv3.client_id, int)
+
+    @pytest.mark.parametrize(
+        "env_vars, expected_log_message",
+        [
+            (
+                {
+                    "STRAVA_CLIENT_ID": "not_an_int",
+                    "STRAVA_CLIENT_SECRET": "123ghp234",
+                },
+                "STRAVA_CLIENT_ID must be a valid integer.",
+            ),  # Invalid client ID
+            (
+                {"STRAVA_CLIENT_SECRET": "123ghp234"},
+                "Please make sure your STRAVA_CLIENT_ID is set",
+            ),  # Missing client ID
+            (
+                {"STRAVA_CLIENT_ID": "12345"},
+                "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET not found",
+            ),  # Missing secret
+        ],
+    )
+    def test_check_credentials_missing_vals(
+        self, caplog, env_vars, expected_log_message
+    ):
+        """Test that if parts of all of the credentials are missing, the method
+        logs the correct warning and returns `None`.
+
+        Here we overwriting environment variables to see if _check_credentials
+        works as expected with various user envt edge cases."""
+
+        with patch.dict(os.environ, env_vars, clear=True):
+            with caplog.at_level(logging.WARNING):
+                credentials = self.apiv3._check_credentials()
+                assert expected_log_message in caplog.text
+                assert credentials is None
+
+
+###### refresh_expired_token method ######
+
+
+@patch.object(stravalib.protocol.ApiV3, "refresh_access_token")
+@patch.object(stravalib.protocol.ApiV3, "_token_expired", return_value=True)
+def test_refresh_expired_token_calls_refresh_access_token(
+    token_expired_mock,
+    refresh_access_token_mock,
+    apiv3_instance,
+):
+    """Test that `refresh_access_token` is called when the token is expired and
+    the refresh token exists.
+
+    This tests mocks refresh_access_token so it doesn't run, but checks to
+    make sure it's called as it should be if all values are present.
+    """
+
+    out = apiv3_instance.refresh_expired_token()
+    refresh_access_token_mock.assert_called_once()
+    # Should return none
+    assert not out
+
+
+###### _token_expired helper ######
+
+
+@pytest.mark.parametrize(
+    "token_expires, expected_result",
+    [
+        (time.time() - 10, True),  # Token expired
+        (time.time() + 10, False),
+    ],
+)
+def test_token_expired(apiv3_instance, token_expires, expected_result):
+    """Test the _token_expired method for both expired and valid tokens."""
+
+    apiv3_instance.token_expires = token_expires
+    assert apiv3_instance._token_expired() == expected_result
+
+
+# caplog is a builtin fixture in pytest that captures logging outputs
+def test_token_expires_is_none_logs_warning(apiv3_instance, caplog):
+    """Test that _token_expired logs a warning if token_expires is None."""
+    apiv3_instance.token_expires = None
+
+    with caplog.at_level(logging.WARNING):
+        assert not apiv3_instance._token_expired()
+        assert (
+            "Please make sure you've set client.token_expires" in caplog.text
+        )
+
+
+class TestRefreshExpiredToken:
+    """Tests for the `refresh_expired_token` method."""
+
+    @pytest.fixture(autouse=True)
+    def setup(self, apiv3_instance):
+        """
+        Shared setup for all tests in this class.
+        Mocks `_token_expired` and `refresh_access_token` and sets up
+        the environment variables.
+        """
+        self.apiv3 = apiv3_instance
+        self.apiv3._token_expired = MagicMock()
+        self.apiv3.refresh_access_token = MagicMock()
+
+    @pytest.mark.parametrize(
+        "token_expired, expected_call", [(True, 1), (False, 0)]
+    )
+    def test_refresh_expired_token_refresh_called(
+        self, token_expired, expected_call
+    ):
+        """Test that `refresh_access_token` method is called when token is expired
+        and not called when valid.
+        """
+        self.apiv3._token_expired.return_value = token_expired
+        self.apiv3.refresh_expired_token()
+
+        assert self.apiv3.refresh_access_token.call_count == expected_call
+
+    def test_refresh_expired_token_no_refresh(self, caplog):
+        """Test that we fail gracefully and log an error if the refresh
+        token isn't populated.
+        """
+
+        self.apiv3.refresh_token = None
+        self.apiv3._token_expired.return_value = True
+
+        with caplog.at_level(logging.WARNING):
+            self.apiv3.refresh_expired_token()
+            assert (
+                "Please set client.refresh_token if you want to use"
+                in caplog.text
+            )