Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

53 add retry handling for login method #58

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 56 additions & 23 deletions folioclient/FolioClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
from openapi_schema_to_json_schema import patternPropertiesHandler

from folioclient.cached_property import cached_property
from folioclient.decorators import retry_on_server_error

CONTENT_TYPE_JSON = "application/json"
try:
HTTPX_TIMEOUT = int(os.environ.get("FOLIOCLIENT_HTTP_TIMEOUT"))
except TypeError:
HTTPX_TIMEOUT = None


class FolioClient:
Expand All @@ -41,7 +48,7 @@ def __init__(self, okapi_url, tenant_id, username, password, ssl_verify=True):
)
self.base_headers = {
"x-okapi-tenant": self.tenant_id,
"content-type": "application/json",
"content-type": CONTENT_TYPE_JSON,
}
self._okapi_headers = {}
self.login()
Expand Down Expand Up @@ -75,6 +82,7 @@ def identifier_types(self):

@cached_property
def module_versions(self):
"""Returns a list of module versions for the current tenant."""
try:
resp = self.folio_get(f"/_/proxy/tenants/{self.tenant_id}/modules")
except httpx.HTTPError:
Expand All @@ -87,6 +95,9 @@ def module_versions(self):

@cached_property
def statistical_codes(self):
"""
Returns a list of statistical codes.
"""
return list(
self.folio_get_all("/statistical-codes", "statisticalCodes", self.cql_all, 1000)
)
Expand Down Expand Up @@ -197,6 +208,9 @@ def okapi_headers(self):
by self.okapi_token. To reset all header values to their initial state:

>>>> del folio_client.okapi_headers

Returns:
dict: The okapi headers.
"""
headers = {
"x-okapi-token": self.okapi_token,
Expand All @@ -218,7 +232,12 @@ def okapi_headers(self):

@property
def okapi_token(self):
"""Property that attempts to return a valid Okapi token, refreshing if needed"""
"""
Property that attempts to return a valid Okapi token, refreshing if needed.

Returns:
str: The Okapi token.
"""
if datetime.now(tz.utc) > (
self.okapi_token_expires
- timedelta(
Expand All @@ -229,26 +248,31 @@ def okapi_token(self):
self.login()
return self._okapi_token

@retry_on_server_error
def login(self):
"""Logs into FOLIO in order to get the okapi token"""
"""Logs into FOLIO in order to get the folio access token."""
payload = {"username": self.username, "password": self.password}
# Transitional implementation to support Poppy and pre-Poppy authentication
url = urljoin(self.okapi_url, "/authn/login-with-expiry")
# Poppy and later
req = httpx.post(
url, json=payload, headers=self.base_headers, timeout=None, verify=self.ssl_verify
)
try:
req = httpx.post(
url,
json=payload,
headers=self.base_headers,
timeout=HTTPX_TIMEOUT,
verify=self.ssl_verify,
)
req.raise_for_status()
except httpx.HTTPError:
except httpx.HTTPStatusError:
# Pre-Poppy
if req.status_code == 404:
url = urljoin(self.okapi_url, "/authn/login")
req = httpx.post(
url,
json=payload,
headers=self.base_headers,
timeout=None,
timeout=HTTPX_TIMEOUT,
verify=self.ssl_verify,
)
req.raise_for_status()
Expand All @@ -266,11 +290,14 @@ def get_single_instance(self, instance_id):

def folio_get_all(self, path, key=None, query=None, limit=10, **kwargs):
"""
Fetches ALL data objects from FOLIO matching `query` in `limit`-size chunks and provides
Fetches ALL data objects from FOLIO matching `query` in `limit`-size chunks and provides
an iterable object yielding a single record at a time until all records have been returned.
- kwargs are passed as additional url parameters to `path`
:param query: The query string to filter the data objects.
:param limit: The maximum number of records to fetch in each chunk.
:param kwargs: Additional url parameters to pass to `path`.
:return: An iterable object yielding a single record at a time.
"""
with httpx.Client(timeout=None, verify=self.ssl_verify) as httpx_client:
with httpx.Client(timeout=HTTPX_TIMEOUT, verify=self.ssl_verify) as httpx_client:
self.httpx_client = httpx_client
offset = 0
query = query or " ".join((self.cql_all, "sortBy id"))
Expand Down Expand Up @@ -299,7 +326,11 @@ def folio_get_all(self, path, key=None, query=None, limit=10, **kwargs):
)

def _construct_query_parameters(self, **kwargs) -> Dict[str, Any]:
"""Private method to construct query parameters for folio_get or httpx client calls"""
"""Private method to construct query parameters for folio_get or httpx client calls

:param kwargs: Additional keyword arguments.
:return: A dictionary of query parameters.
"""
params = kwargs
if query := kwargs.get("query"):
if query.startswith(("?", "query=")): # Handle previous query specification syntax
Expand All @@ -319,7 +350,7 @@ def folio_get(self, path, key=None, query="", query_params: dict = None):
* key: Key in JSON response from FOLIO that includes the array of results for query APIs
* query: For backwards-compatibility
* query_params: Additional query parameters for the specified path. May also be used for
`query`
`query`
"""
url = urljoin(self.okapi_url, path).rstrip("/")
if query and query_params:
Expand All @@ -334,7 +365,7 @@ def folio_get(self, path, key=None, query="", query_params: dict = None):
url,
params=query_params,
headers=self.okapi_headers,
timeout=None,
timeout=HTTPX_TIMEOUT,
verify=self.ssl_verify,
)
req.raise_for_status()
Expand All @@ -348,7 +379,6 @@ def folio_put(self, path, payload, query_params: dict = None):
url,
headers=self.okapi_headers,
json=payload,
verify=self.ssl_verify,
params=query_params,
)
req.raise_for_status()
Expand All @@ -365,7 +395,6 @@ def folio_post(self, path, payload, query_params: dict = None):
url,
headers=self.okapi_headers,
json=payload,
verify=self.ssl_verify,
params=query_params,
)
req.raise_for_status()
Expand All @@ -376,7 +405,7 @@ def folio_post(self, path, payload, query_params: dict = None):

def get_folio_http_client(self):
"""Returns a httpx client for use in FOLIO communication"""
return httpx.Client(timeout=None, verify=self.ssl_verify)
return httpx.Client(timeout=HTTPX_TIMEOUT, verify=self.ssl_verify)

def folio_get_single_object(self, path):
"""Fetches data from FOLIO and turns it into a json object as is"""
Expand Down Expand Up @@ -411,7 +440,7 @@ def get_latest_from_github(
owner, repo, filepath: str, personal_access_token="", ssl_verify=True
): # noqa: S107
github_headers = {
"content-type": "application/json",
"content-type": CONTENT_TYPE_JSON,
"User-Agent": "Folio Client (https://github.com/FOLIO-FSE/FolioClient)",
}
if personal_access_token:
Expand All @@ -423,7 +452,7 @@ def get_latest_from_github(
req = httpx.get(
latest_path,
headers=github_headers,
timeout=None,
timeout=HTTPX_TIMEOUT,
follow_redirects=True,
verify=ssl_verify,
)
Expand All @@ -436,7 +465,7 @@ def get_latest_from_github(
req = httpx.get(
latest_path,
headers=github_headers,
timeout=None,
timeout=HTTPX_TIMEOUT,
follow_redirects=True,
verify=ssl_verify,
)
Expand All @@ -454,7 +483,7 @@ def get_from_github(
): # noqa: S107
version = self.get_module_version(repo)
github_headers = {
"content-type": "application/json",
"content-type": CONTENT_TYPE_JSON,
"User-Agent": "Folio Client (https://github.com/FOLIO-FSE/FolioClient)",
}
if personal_access_token:
Expand All @@ -467,7 +496,7 @@ def get_from_github(
req = httpx.get(
f_path,
headers=github_headers,
timeout=None,
timeout=HTTPX_TIMEOUT,
follow_redirects=True,
verify=ssl_verify,
)
Expand All @@ -480,7 +509,11 @@ def get_from_github(
f_path = f"https://raw.githubusercontent.com/{owner}/{repo}/{version}/{filepath}"
# print(latest_path)
req = httpx.get(
f_path, headers=github_headers, timeout=None, follow_redirects=True, verify=ssl_verify
f_path,
headers=github_headers,
timeout=HTTPX_TIMEOUT,
follow_redirects=True,
verify=ssl_verify,
)
req.raise_for_status()
if filepath.endswith("json"):
Expand Down
44 changes: 44 additions & 0 deletions folioclient/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""This module contains decorators for the FolioClient package."""

import logging
import os
import time
from functools import wraps

import httpx


def retry_on_server_error(func):
"""Retry a function if a temporary server error is encountered.

Returns:
The decorated function.
"""

@wraps(func)
def wrapper(*args, **kwargs):
retry_factor = float(os.environ.get("FOLIOCLIENT_SERVER_ERROR_RETRY_FACTOR", "3"))
max_retries = int(os.environ.get("FOLIOCLIENT_MAX_SERVER_ERROR_RETRIES", "0"))
retry_delay = int(os.environ.get("FOLIOCLIENT_SERVER_ERROR_RETRY_DELAY", "10"))
for retry in range(max_retries + 1):
try:
return func(*args, **kwargs)
except httpx.HTTPStatusError as exc:
if exc.response.status_code in [502, 503, 504]:
if retry == max_retries:
logging.exception(
f'Server error requesting new auth token: "{exc.response}"'
"Maximum number of retries reached, giving up."
)
raise
logging.info(
f"FOLIOCLIENT: Server error requesting new auth token:"
f' "{exc.response}". Retrying again in {retry_delay} seconds. '
f"Retry {retry + 1}/{max_retries}"
)
time.sleep(retry_delay)
retry_delay *= retry_factor
else:
raise

return wrapper
Loading