mirror of https://github.com/caronc/apprise
Discord Rate Limiting (429 Error code) handling (#901)
parent
78f5693382
commit
a94700b0eb
|
@ -50,6 +50,9 @@
|
||||||
import re
|
import re
|
||||||
import requests
|
import requests
|
||||||
from json import dumps
|
from json import dumps
|
||||||
|
from datetime import timedelta
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
from .NotifyBase import NotifyBase
|
from .NotifyBase import NotifyBase
|
||||||
from ..common import NotifyImageSize
|
from ..common import NotifyImageSize
|
||||||
|
@ -84,6 +87,17 @@ class NotifyDiscord(NotifyBase):
|
||||||
# Allows the user to specify the NotifyImageSize object
|
# Allows the user to specify the NotifyImageSize object
|
||||||
image_size = NotifyImageSize.XY_256
|
image_size = NotifyImageSize.XY_256
|
||||||
|
|
||||||
|
# Discord is kind enough to return how many more requests we're allowed to
|
||||||
|
# continue to make within it's header response as:
|
||||||
|
# X-RateLimit-Reset: The epoc time (in seconds) we can expect our
|
||||||
|
# rate-limit to be reset.
|
||||||
|
# X-RateLimit-Remaining: an integer identifying how many requests we're
|
||||||
|
# still allow to make.
|
||||||
|
request_rate_per_sec = 0
|
||||||
|
|
||||||
|
# Taken right from google.auth.helpers:
|
||||||
|
clock_skew = timedelta(seconds=10)
|
||||||
|
|
||||||
# The maximum allowable characters allowed in the body per message
|
# The maximum allowable characters allowed in the body per message
|
||||||
body_maxlen = 2000
|
body_maxlen = 2000
|
||||||
|
|
||||||
|
@ -215,6 +229,12 @@ class NotifyDiscord(NotifyBase):
|
||||||
# dynamically generated avatar url images
|
# dynamically generated avatar url images
|
||||||
self.avatar_url = avatar_url
|
self.avatar_url = avatar_url
|
||||||
|
|
||||||
|
# For Tracking Purposes
|
||||||
|
self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Default to 1.0
|
||||||
|
self.ratelimit_remaining = 1.0
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||||
|
@ -343,7 +363,8 @@ class NotifyDiscord(NotifyBase):
|
||||||
# Otherwise return
|
# Otherwise return
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _send(self, payload, attach=None, params=None, **kwargs):
|
def _send(self, payload, attach=None, params=None, rate_limit=1,
|
||||||
|
**kwargs):
|
||||||
"""
|
"""
|
||||||
Wrapper to the requests (post) object
|
Wrapper to the requests (post) object
|
||||||
"""
|
"""
|
||||||
|
@ -365,8 +386,25 @@ class NotifyDiscord(NotifyBase):
|
||||||
))
|
))
|
||||||
self.logger.debug('Discord Payload: %s' % str(payload))
|
self.logger.debug('Discord Payload: %s' % str(payload))
|
||||||
|
|
||||||
# Always call throttle before any remote server i/o is made
|
# By default set wait to None
|
||||||
self.throttle()
|
wait = None
|
||||||
|
|
||||||
|
if self.ratelimit_remaining <= 0.0:
|
||||||
|
# Determine how long we should wait for or if we should wait at
|
||||||
|
# all. This isn't fool-proof because we can't be sure the client
|
||||||
|
# time (calling this script) is completely synced up with the
|
||||||
|
# Gitter server. One would hope we're on NTP and our clocks are
|
||||||
|
# the same allowing this to role smoothly:
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
if now < self.ratelimit_reset:
|
||||||
|
# We need to throttle for the difference in seconds
|
||||||
|
wait = abs(
|
||||||
|
(self.ratelimit_reset - now + self.clock_skew)
|
||||||
|
.total_seconds())
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made;
|
||||||
|
self.throttle(wait=wait)
|
||||||
|
|
||||||
# Perform some simple error checking
|
# Perform some simple error checking
|
||||||
if isinstance(attach, AttachBase):
|
if isinstance(attach, AttachBase):
|
||||||
|
@ -401,6 +439,22 @@ class NotifyDiscord(NotifyBase):
|
||||||
verify=self.verify_certificate,
|
verify=self.verify_certificate,
|
||||||
timeout=self.request_timeout,
|
timeout=self.request_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Handle rate limiting (if specified)
|
||||||
|
try:
|
||||||
|
# Store our rate limiting (if provided)
|
||||||
|
self.ratelimit_remaining = \
|
||||||
|
float(r.headers.get(
|
||||||
|
'X-RateLimit-Remaining'))
|
||||||
|
self.ratelimit_reset = datetime.fromtimestamp(
|
||||||
|
int(r.headers.get('X-RateLimit-Reset')),
|
||||||
|
timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# This is returned if we could not retrieve this
|
||||||
|
# information gracefully accept this state and move on
|
||||||
|
pass
|
||||||
|
|
||||||
if r.status_code not in (
|
if r.status_code not in (
|
||||||
requests.codes.ok, requests.codes.no_content):
|
requests.codes.ok, requests.codes.no_content):
|
||||||
|
|
||||||
|
@ -408,6 +462,20 @@ class NotifyDiscord(NotifyBase):
|
||||||
status_str = \
|
status_str = \
|
||||||
NotifyBase.http_response_code_lookup(r.status_code)
|
NotifyBase.http_response_code_lookup(r.status_code)
|
||||||
|
|
||||||
|
if r.status_code == requests.codes.too_many_requests \
|
||||||
|
and rate_limit > 0:
|
||||||
|
|
||||||
|
# handle rate limiting
|
||||||
|
self.logger.warning(
|
||||||
|
'Discord rate limiting in effect; '
|
||||||
|
'blocking for %.2f second(s)',
|
||||||
|
self.ratelimit_remaining)
|
||||||
|
|
||||||
|
# Try one more time before failing
|
||||||
|
return self._send(
|
||||||
|
payload=payload, attach=attach, params=params,
|
||||||
|
rate_limit=rate_limit - 1, **kwargs)
|
||||||
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
'Failed to send {}to Discord notification: '
|
'Failed to send {}to Discord notification: '
|
||||||
'{}{}error={}.'.format(
|
'{}{}error={}.'.format(
|
||||||
|
|
|
@ -32,7 +32,8 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from datetime import timezone
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
@ -182,6 +183,11 @@ def test_plugin_discord_general(mock_post):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Turn off clock skew for local testing
|
||||||
|
NotifyDiscord.clock_skew = timedelta(seconds=0)
|
||||||
|
# Epoch time:
|
||||||
|
epoch = datetime.fromtimestamp(0, timezone.utc)
|
||||||
|
|
||||||
# Initialize some generic (but valid) tokens
|
# Initialize some generic (but valid) tokens
|
||||||
webhook_id = 'A' * 24
|
webhook_id = 'A' * 24
|
||||||
webhook_token = 'B' * 64
|
webhook_token = 'B' * 64
|
||||||
|
@ -189,6 +195,12 @@ def test_plugin_discord_general(mock_post):
|
||||||
# Prepare Mock
|
# Prepare Mock
|
||||||
mock_post.return_value = requests.Request()
|
mock_post.return_value = requests.Request()
|
||||||
mock_post.return_value.status_code = requests.codes.ok
|
mock_post.return_value.status_code = requests.codes.ok
|
||||||
|
mock_post.return_value.content = ''
|
||||||
|
mock_post.return_value.headers = {
|
||||||
|
'X-RateLimit-Reset': (
|
||||||
|
datetime.now(timezone.utc) - epoch).total_seconds(),
|
||||||
|
'X-RateLimit-Remaining': 1,
|
||||||
|
}
|
||||||
|
|
||||||
# Invalid webhook id
|
# Invalid webhook id
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
|
@ -208,10 +220,85 @@ def test_plugin_discord_general(mock_post):
|
||||||
webhook_id=webhook_id,
|
webhook_id=webhook_id,
|
||||||
webhook_token=webhook_token,
|
webhook_token=webhook_token,
|
||||||
footer=True, thumbnail=False)
|
footer=True, thumbnail=False)
|
||||||
|
assert obj.ratelimit_remaining == 1
|
||||||
|
|
||||||
# Test that we get a string response
|
# Test that we get a string response
|
||||||
assert isinstance(obj.url(), str) is True
|
assert isinstance(obj.url(), str) is True
|
||||||
|
|
||||||
|
# This call includes an image with it's payload:
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO) is True
|
||||||
|
|
||||||
|
# Force a case where there are no more remaining posts allowed
|
||||||
|
mock_post.return_value.headers = {
|
||||||
|
'X-RateLimit-Reset': (
|
||||||
|
datetime.now(timezone.utc) - epoch).total_seconds(),
|
||||||
|
'X-RateLimit-Remaining': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# This call includes an image with it's payload:
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO) is True
|
||||||
|
|
||||||
|
# behind the scenes, it should cause us to update our rate limit
|
||||||
|
assert obj.send(body="test") is True
|
||||||
|
assert obj.ratelimit_remaining == 0
|
||||||
|
|
||||||
|
# This should cause us to block
|
||||||
|
mock_post.return_value.headers = {
|
||||||
|
'X-RateLimit-Reset': (
|
||||||
|
datetime.now(timezone.utc) - epoch).total_seconds(),
|
||||||
|
'X-RateLimit-Remaining': 10,
|
||||||
|
}
|
||||||
|
assert obj.send(body="test") is True
|
||||||
|
assert obj.ratelimit_remaining == 10
|
||||||
|
|
||||||
|
# Reset our variable back to 1
|
||||||
|
mock_post.return_value.headers = {
|
||||||
|
'X-RateLimit-Reset': (
|
||||||
|
datetime.now(timezone.utc) - epoch).total_seconds(),
|
||||||
|
'X-RateLimit-Remaining': 1,
|
||||||
|
}
|
||||||
|
# Handle cases where our epoch time is wrong
|
||||||
|
del mock_post.return_value.headers['X-RateLimit-Reset']
|
||||||
|
assert obj.send(body="test") is True
|
||||||
|
|
||||||
|
# Return our object, but place it in the future forcing us to block
|
||||||
|
mock_post.return_value.headers = {
|
||||||
|
'X-RateLimit-Reset': (
|
||||||
|
datetime.now(timezone.utc) - epoch).total_seconds() + 1,
|
||||||
|
'X-RateLimit-Remaining': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.ratelimit_remaining = 0
|
||||||
|
assert obj.send(body="test") is True
|
||||||
|
|
||||||
|
# Test 429 error response
|
||||||
|
mock_post.return_value.status_code = requests.codes.too_many_requests
|
||||||
|
|
||||||
|
# The below will attempt a second transmission and fail (because we didn't
|
||||||
|
# set up a second post request to pass) :)
|
||||||
|
assert obj.send(body="test") is False
|
||||||
|
|
||||||
|
# Return our object, but place it in the future forcing us to block
|
||||||
|
mock_post.return_value.status_code = requests.codes.ok
|
||||||
|
mock_post.return_value.headers = {
|
||||||
|
'X-RateLimit-Reset': (
|
||||||
|
datetime.now(timezone.utc) - epoch).total_seconds() - 1,
|
||||||
|
'X-RateLimit-Remaining': 0,
|
||||||
|
}
|
||||||
|
assert obj.send(body="test") is True
|
||||||
|
|
||||||
|
# Return our limits to always work
|
||||||
|
obj.ratelimit_remaining = 1
|
||||||
|
|
||||||
|
# Return our headers to normal
|
||||||
|
mock_post.return_value.headers = {
|
||||||
|
'X-RateLimit-Reset': (
|
||||||
|
datetime.now(timezone.utc) - epoch).total_seconds(),
|
||||||
|
'X-RateLimit-Remaining': 1,
|
||||||
|
}
|
||||||
|
|
||||||
# This call includes an image with it's payload:
|
# This call includes an image with it's payload:
|
||||||
assert obj.notify(
|
assert obj.notify(
|
||||||
body='body', title='title', notify_type=NotifyType.INFO) is True
|
body='body', title='title', notify_type=NotifyType.INFO) is True
|
||||||
|
|
Loading…
Reference in New Issue