"""
thorn.webhook.request
=====================
Webhook HTTP requests.
"""
from __future__ import absolute_import, unicode_literals
import thorn
import requests
from celery import uuid
from celery.utils import cached_property
from requests.exceptions import ConnectionError, Timeout
from requests.packages.urllib3.util.url import parse_url
from vine import maybe_promise, promise
from vine.abstract import Thenable, ThenableProxy
from ._state import app_or_default
from .utils.compat import bytes_if_py2, restore_from_keys
from .utils.log import get_logger
from .validators import deserialize_validator, serialize_validator
__all__ = ['Request']
DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; thorn/{0}; {1})'.format(
thorn.__version__, requests.utils.default_user_agent(),
)
REQUEST_REPR = """\
<{0}: {1.event} -> {1.subscriber.url} sender={1.sender!r}>\
"""
logger = get_logger(__name__)
@Thenable.register
[docs]class Request(ThenableProxy):
"""Webhook HTTP request
:param event: Name of event.
:param data: Event payload.
:param sender: Sender of event (or :const:`None`).
:param subscriber: Subscriber to dispatch the request for.
:keyword on_success: Optional callback called if the HTTP request
succeeds. Must take single argument: ``request``.
:keyword on_timeout: Optional callback called if the HTTP request
times out. Must have signature: ``(request, exc)``.
:keyword on_error: Optional callback called if the HTTP request
fails. Must have signature: ``(request, exc)``.
:keyword headers: Additional HTTP headers to send with the request.
:keyword user_agent: Set custom HTTP user agent.
:keyword recipient_validators: List of serialized recipient validators.
:keyword allow_keepalive: Allow reusing session for this HTTP request.
Enabled by default.
:keyword retry: Retry in the event of timeout/failure?
Disabled by default.
:keyword retry_max: Maximum number of times to retry before giving up.
Default is 3.
:keyword retry_delay: Delay between retries in seconds int/float.
Default is 60 seconds.
"""
app = None
Session = requests.Session
#: Holds the response after the HTTP request is performed.
response = None
#: Tuple of exceptions considered a connection error.
connection_errors = (ConnectionError,)
#: Tuple of exceptions considered a timeout error.
timeout_errors = (Timeout,)
#: HTTP User-Agent header.
user_agent = DEFAULT_USER_AGENT
def __init__(self, event, data, sender, subscriber,
id=None, on_success=None, on_error=None,
timeout=None, on_timeout=None,
retry=None, retry_max=None, retry_delay=None,
headers=None, user_agent=None, app=None,
recipient_validators=None,
allow_keepalive=True):
self.app = app_or_default(app or self.app)
self.id = id or uuid()
self.event = event
self.data = data
self.sender = sender
self.subscriber = subscriber
self.timeout = timeout
self.on_success = on_success
self.on_timeout = maybe_promise(on_timeout)
self.on_error = on_error
self.retry = self.app.settings.THORN_RETRY if retry is None else retry
self.retry_max = (
self.app.settings.THORN_RETRY_MAX
if retry_max is None else retry_max)
self.retry_delay = (
self.app.settings.THORN_RETRY_DELAY
if retry_delay is None else retry_delay)
if recipient_validators is None:
recipient_validators = self.app.settings.THORN_RECIPIENT_VALIDATORS
self.allow_keepalive = allow_keepalive
self._recipient_validators = recipient_validators
self.response = None
self._headers = headers
self._set_promise_target(promise(
args=(self,), callback=self.on_success, on_error=self.on_error,
))
if user_agent:
self.user_agent = user_agent
[docs] def validate_recipient(self, url):
return [validate(url) for validate in self.recipient_validators]
[docs] def sign_request(self, subscriber, data):
return subscriber.sign(data)
[docs] def dispatch(self, session=None, propagate=False, close_session=False):
if self.cancelled:
return
self.validate_recipient(self.subscriber.url)
if session is None or not self.allow_keepalive:
session, close_session = self.Session(), True
try:
self.response = session.post(
url=self.subscriber.url,
data=self.data,
timeout=self.timeout,
headers=self.annotate_headers({
'Hook-HMAC': self.sign_request(self.subscriber, self.data),
'Hook-Subscription': str(self.subscriber.uuid),
}),
)
except self.timeout_errors as exc:
self.handle_timeout_error(exc, propagate=propagate)
except self.connection_errors as exc:
self.handle_connection_error(exc, propagate=propagate)
else:
self._p()
finally:
if close_session and session is not None:
session.close()
return self
[docs] def handle_timeout_error(self, exc, propagate=False):
logger.info('Timed out while dispatching webhook request: %r',
exc, exc_info=1, extra={'data': self.as_dict()})
if self.on_timeout:
return self.on_timeout(self, exc)
return self._p.throw(exc, propagate=propagate)
[docs] def handle_connection_error(self, exc, propagate=False):
logger.error('Error dispatching webhook request: %r',
exc, exc_info=1, extra={'data': self.as_dict()})
self._p.throw(exc, propagate=propagate)
[docs] def as_dict(self):
"""Return a dictionary representation of this request
suitable for serialization."""
return {
'id': self.id,
'event': self.event,
'sender': self.sender,
'subscriber': self.subscriber.as_dict(),
'data': self.data,
'timeout': self.timeout,
'retry': self.retry,
'retry_max': self.retry_max,
'retry_delay': self.retry_delay,
'recipient_validators': self._serialize_validators(
self._recipient_validators,
),
'allow_keepalive': self.allow_keepalive,
}
def _serialize_validators(self, validators):
return [serialize_validator(v) for v in validators]
def __repr__(self):
return bytes_if_py2(REQUEST_REPR.format(type(self).__name__, self))
def __reduce__(self):
return restore_from_keys, (type(self), (), self.__reduce_keys__())
def __reduce_keys__(self):
return dict(
self.as_dict(),
headers=self._headers,
user_agent=self.user_agent,
)
@cached_property
@property
def default_headers(self):
return {
'Content-Type': self.subscriber.content_type,
'User-Agent': self.user_agent,
'Hook-Event': self.event,
'Hook-Delivery': self.id,
}
@cached_property
[docs] def urlident(self):
"""Used to order HTTP requests by URL."""
url = parse_url(self.subscriber.url)
return url.host, url.port or 80, url.scheme or 'http'
@property
def value(self):
return self.response # here for Thenable-compatiblity.
@cached_property
[docs] def recipient_validators(self):
return [
deserialize_validator(v) for v in self._recipient_validators
]