Source code for ahoyhoy.client.client

import abc
import logging

from requests.exceptions import RequestException
from retry.api import retry_call

from ..lb.exceptions import NoAvailableEndpointsLbException
from .exceptions import AhoyhoyRequestsException, NoAvailableEndpointsClientException


logger = logging.getLogger(__name__)


[docs]class Client(object): """ A load balancing, circuit breaking client. Accepts load balancer instance as an argument. Client can be a duck-typed requests object. Usage examples: 1. Client with RoundRobin LB and bad hosts >>> from ahoyhoy.utils import Host >>> from ahoyhoy.lb.providers import ListProvider >>> from ahoyhoy.lb import RoundRobinLB >>> bad_host1 = Host('google1.com1', '80') >>> bad_host2 = Host('google2.com2', '80') >>> good_host = Host('google.com', '80') >>> rrlb = RoundRobinLB(ListProvider(bad_host1, bad_host2, good_host)) >>> client = Client(rrlb) >>> client.get('/') <Response [200]> *Note! Client only accepts HTTP calls for now.* Session's attributes and methods (except HTTP calls) are unavailable for calls through the Client's instance. Because of the dynamic nature of :class:`~ahoyhoy.endpoints.Endpoint` s, all parameters (such as headers etc.) have to be changed through the client-specific API. Consider these examples: >>> client.headers.update({'bla': 'foo'}) Traceback (most recent call last): ... AttributeError: No such attribute: 'headers'. Only HTTP methods can be called for now. >>> c = Client(rrlb, headers={'bla': 'foo'}) >>> response = c.get('/') >>> assert 'bla' in response.request.headers.keys() """ # Retries logic: # +---------------------+ # | client.get('/url') | # +---------------------+ # | # v # +---------------------+ # +------>----+------------>------->| Resolve an Endpoint | # | | +---------------------+ # | | | # | | v # | | /----------------------------\ # | | | Did LB raise `no available | No # | | | endpoints` exception? |---------+ # | | \----------------------------/ | # | | | Yes | # | | v | # | +-----------------+ No /---------------------------------\ | # | |Update endpoints |<-----| Did max retries amount for up- | | # | | list | | dating endpoints list exceeded? | | # | +-----------------+ \---------------------------------/ | # | | Yes | # | v | # | +-------------------------------+ | # | | Client raises `no available | | # | | endpoints` exception | | # | +-------------------------------+ | # | v # | +--------------------------+ # | | call Endpoint | # | | (endpoint has its own | # | | retries) | # | +--------------------------+ # | : # | v # | Yes /--------------------\ # +-----------------------------------------------------------| any Exception from | # | failed request? | # \--------------------/ # | No # v # +---------------------+ # |return the response | # +---------------------+ HTTP_CALLS = ( 'get', 'options', 'post', 'put', 'head', 'patch', 'delete', ) def __init__(self, lb, ep_list_update_tries=1, **ep_kwargs): """ :param lb: instance of :class:`~ahoyhoy.lb.iloadbalancer.ILoadBalancer` :param ep_list_update_tries: how many times to try to update endpoints list in LB :param ep_kwargs: arguments to pass to an Endpoint ('retry_http_call_func', 'headers') """ # abc.ABCMeta will be type for lb if type(lb) is abc.ABCMeta: raise TypeError("Load Balancer needs to be instantiated first.") self._lb = lb self._ep_list_update_tries = ep_list_update_tries self._ep_kwargs = ep_kwargs logger.debug("Create client with lb %s and ep_kwargs %s", self._lb, self._ep_kwargs) def _resolve(self): """ LB will pick and return an Endpoint. If no Endpoints are available, update the LB list and raise the exception (so it can be then retried). """ try: ep = self._lb.pick() except NoAvailableEndpointsLbException: logger.exception("There are no endpoints available, so update the list for more tries.") self._lb.update() raise logger.debug("Endpoint was picked: %s", ep) return ep
[docs] def resolve(self): """ Resolve an Endpoint. If `NoAvailableEndpointsLbException` was raised, it's possible to add more "tries", update Endpoints list and try to resolve it one more time. (see ._resolve) """ ep = retry_call(self._resolve, exceptions=NoAvailableEndpointsLbException, tries=self._ep_list_update_tries) return ep
@staticmethod def _augment_endpoint(ep, **kwargs): """ Set additional parameters to endpoint. :param headers dict: :param retry_http_call_func partial: function which accepts http call func and its arguments """ logger.debug("_augment_endpoint is called") headers = kwargs.pop('headers', None) retry_http_call_func = kwargs.pop('retry_http_call_func', None) if retry_http_call_func: logger.debug("Set retries: %s", retry_http_call_func) ep.set_retry(retry_http_call_func) if headers: logger.debug("Set headers: %s", headers) ep.set_headers(headers) return ep def _call_endpoint(self, name, *args, **kwargs): """ Resolve Endpoint, assign all required attributes to it and make an HTTP call. """ ep = self.resolve() ep = self._augment_endpoint(ep, **self._ep_kwargs) logger.debug("Call an Endpoint: %s", ep) try: response = getattr(ep, name)(*args, **kwargs) logger.debug("Received the response from the Endpoint.") except RequestException: logger.exception("Got RequestException, raise AhoyhoyRequestsException.") raise AhoyhoyRequestsException except Exception as e: logger.exception("Got unexpected exception: %s", str(e)) raise return response def _dispatch(self, *args, **kwargs): """ Delegates calls to the Endpoint through the `retry_call` function. At this stage retries are used to resolve a new Endpoint, if all of the previous ones failed. This function will retry unlimited amount of times until `NoAvailableEndpointsLbException` is finally raised. This will raise final `NoAvailableEndpointsClientException`. """ try: # will try to call an endpoint unlimited amount of times until there are no available endpoints result = retry_call(self._call_endpoint, fargs=args, fkwargs=kwargs, exceptions=AhoyhoyRequestsException) except NoAvailableEndpointsLbException: raise NoAvailableEndpointsClientException( "No available endpoints are found and maximum retries amount for updating an endpoints list exceeded.") return result def __getattr__(self, name): """ Other methods of endpoint can't be called through the client, just because it can be different endpoint already. TODO: delegate all other actions (like client.headers.update(..)) to the client. """ if name in self.HTTP_CALLS: def wrapper(*args, **kwargs): logger.debug("Got an HTTP call %s with arguments: %s, %s", name, args, kwargs) return self._dispatch(name, *args, **kwargs) return wrapper raise AttributeError("No such attribute: '{}'. Only HTTP methods can be called for now.".format(name))
[docs]def SimpleClient(session=None, retry_http_call=None): """ Shortcut >>> from ahoyhoy.client.client import SimpleClient >>> client = SimpleClient().get('http://google.com') """ from ahoyhoy.endpoints import SimpleHttpEndpoint return SimpleHttpEndpoint(session=session, retry=retry_http_call)