diff options
author | Mike Crute <mike@crute.us> | 2019-04-02 05:23:23 +0000 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2020-06-16 00:14:47 +0000 |
commit | 1080ae62dd6ae3a47b0f222537b5343f6715490f (patch) | |
tree | a17bbdb5d83262a9709bb64679c3ffb0c81e2ceb | |
parent | 95b3f19c042b8dd582a46c1b58e2b0c5e81f4d5b (diff) | |
download | pydora-rate-limits.tar.bz2 pydora-rate-limits.tar.xz pydora-rate-limits.zip |
WIP: rate limitsrate-limits
-rw-r--r-- | pandora/client.py | 36 | ||||
-rw-r--r-- | pandora/clientbuilder.py | 5 | ||||
-rw-r--r-- | pandora/ratelimit.py | 164 | ||||
-rw-r--r-- | pydora/player.py | 28 |
4 files changed, 227 insertions, 6 deletions
diff --git a/pandora/client.py b/pandora/client.py index a98056c..ff27678 100644 --- a/pandora/client.py +++ b/pandora/client.py | |||
@@ -14,6 +14,7 @@ For simplicity use a client builder from pandora.clientbuilder to create an | |||
14 | instance of a client. | 14 | instance of a client. |
15 | """ | 15 | """ |
16 | from . import errors | 16 | from . import errors |
17 | from .ratelimit import WarningTokenBucket | ||
17 | 18 | ||
18 | 19 | ||
19 | class BaseAPIClient: | 20 | class BaseAPIClient: |
@@ -36,7 +37,27 @@ class BaseAPIClient: | |||
36 | partner_password, | 37 | partner_password, |
37 | device, | 38 | device, |
38 | default_audio_quality=MED_AUDIO_QUALITY, | 39 | default_audio_quality=MED_AUDIO_QUALITY, |
40 | rate_limiter=WarningTokenBucket, | ||
39 | ): | 41 | ): |
42 | """Initialize an API Client | ||
43 | |||
44 | transport | ||
45 | instance of a Pandora transport | ||
46 | partner_user | ||
47 | partner username | ||
48 | partner_password | ||
49 | partner password | ||
50 | device | ||
51 | device type identifier | ||
52 | default_audio_quality | ||
53 | audio quality level, one of the *_AUDIO_QUALITY constants in the | ||
54 | BaseAPIClient class | ||
55 | rate_limiter | ||
56 | class (not instance) implementing the pandora.ratelimit.TokenBucket | ||
57 | interface. Used by various client components to handle rate | ||
58 | limits with the Pandora API. The default rate limited warns when | ||
59 | the rate limit has been exceeded but does not enforce the limit. | ||
60 | """ | ||
40 | self.transport = transport | 61 | self.transport = transport |
41 | self.partner_user = partner_user | 62 | self.partner_user = partner_user |
42 | self.partner_password = partner_password | 63 | self.partner_password = partner_password |
@@ -45,6 +66,17 @@ class BaseAPIClient: | |||
45 | self.username = None | 66 | self.username = None |
46 | self.password = None | 67 | self.password = None |
47 | 68 | ||
69 | # Global rate limiter for all methods, allows a call rate of 2 calls | ||
70 | # per second. This limit is based on nothing but seems sane to prevent | ||
71 | # runaway code from hitting Pandora too hard. | ||
72 | self._api_limiter = rate_limiter(120, 1, 1) | ||
73 | |||
74 | # Rate limiter for the get_playlist API which has a much lower | ||
75 | # server-side limit than other APIs. This was determined | ||
76 | # experimentally. This is applied before and in addition to the global | ||
77 | # API rate limit. | ||
78 | self._playlist_limiter = rate_limiter(5, 1, 1) | ||
79 | |||
48 | def _partner_login(self): | 80 | def _partner_login(self): |
49 | partner = self.transport( | 81 | partner = self.transport( |
50 | "auth.partnerLogin", | 82 | "auth.partnerLogin", |
@@ -98,6 +130,8 @@ class BaseAPIClient: | |||
98 | return [] | 130 | return [] |
99 | 131 | ||
100 | def __call__(self, method, **kwargs): | 132 | def __call__(self, method, **kwargs): |
133 | self._api_limiter.consume(1) | ||
134 | |||
101 | try: | 135 | try: |
102 | return self.transport(method, **kwargs) | 136 | return self.transport(method, **kwargs) |
103 | except errors.InvalidAuthToken: | 137 | except errors.InvalidAuthToken: |
@@ -125,6 +159,8 @@ class APIClient(BaseAPIClient): | |||
125 | def get_playlist(self, station_token, additional_urls=None): | 159 | def get_playlist(self, station_token, additional_urls=None): |
126 | from .models.playlist import Playlist | 160 | from .models.playlist import Playlist |
127 | 161 | ||
162 | self._playlist_limiter.consume(1) | ||
163 | |||
128 | if additional_urls is None: | 164 | if additional_urls is None: |
129 | additional_urls = [] | 165 | additional_urls = [] |
130 | 166 | ||
diff --git a/pandora/clientbuilder.py b/pandora/clientbuilder.py index 2b11ffb..1eb3811 100644 --- a/pandora/clientbuilder.py +++ b/pandora/clientbuilder.py | |||
@@ -8,6 +8,7 @@ import os.path | |||
8 | 8 | ||
9 | from configparser import ConfigParser | 9 | from configparser import ConfigParser |
10 | from .client import APIClient | 10 | from .client import APIClient |
11 | from .ratelimit import WarningTokenBucket | ||
11 | from .transport import Encryptor, APITransport, DEFAULT_API_HOST | 12 | from .transport import Encryptor, APITransport, DEFAULT_API_HOST |
12 | 13 | ||
13 | 14 | ||
@@ -94,8 +95,9 @@ class APIClientBuilder: | |||
94 | 95 | ||
95 | DEFAULT_CLIENT_CLASS = APIClient | 96 | DEFAULT_CLIENT_CLASS = APIClient |
96 | 97 | ||
97 | def __init__(self, client_class=None): | 98 | def __init__(self, client_class=None, rate_limiter=WarningTokenBucket): |
98 | self.client_class = client_class or self.DEFAULT_CLIENT_CLASS | 99 | self.client_class = client_class or self.DEFAULT_CLIENT_CLASS |
100 | self.rate_limiter = rate_limiter | ||
99 | 101 | ||
100 | def build_from_settings_dict(self, settings): | 102 | def build_from_settings_dict(self, settings): |
101 | enc = Encryptor(settings["DECRYPTION_KEY"], settings["ENCRYPTION_KEY"]) | 103 | enc = Encryptor(settings["DECRYPTION_KEY"], settings["ENCRYPTION_KEY"]) |
@@ -116,6 +118,7 @@ class APIClientBuilder: | |||
116 | settings["PARTNER_PASSWORD"], | 118 | settings["PARTNER_PASSWORD"], |
117 | settings["DEVICE"], | 119 | settings["DEVICE"], |
118 | quality, | 120 | quality, |
121 | self.rate_limiter, | ||
119 | ) | 122 | ) |
120 | 123 | ||
121 | 124 | ||
diff --git a/pandora/ratelimit.py b/pandora/ratelimit.py new file mode 100644 index 0000000..427ede9 --- /dev/null +++ b/pandora/ratelimit.py | |||
@@ -0,0 +1,164 @@ | |||
1 | """ | ||
2 | Pandora Rate Limiter | ||
3 | """ | ||
4 | import time | ||
5 | import warnings | ||
6 | |||
7 | from .errors import PandoraException | ||
8 | |||
9 | |||
10 | class RateLimitExceeded(PandoraException): | ||
11 | """Exception thrown when rate limit is exceeded | ||
12 | """ | ||
13 | |||
14 | code = 0 | ||
15 | message = "Rate Limit Exceeded" | ||
16 | |||
17 | |||
18 | class TokenBucketCallbacks(object): | ||
19 | """Interface for TokenBucket Callbacks | ||
20 | |||
21 | These methods get called by the token bucket during certain parts of the | ||
22 | bucket lifecycle. | ||
23 | """ | ||
24 | |||
25 | def near_depletion(self, tokens_left): | ||
26 | """Bucket near depletion callback | ||
27 | |||
28 | This callback is called when the token bucket is nearly depleted, as | ||
29 | defined by the depletion_level member of the TokenBucket class. This | ||
30 | method provides a hook for clients to warn when the token bucket is | ||
31 | nearly consumed. The return value of this method is ignored. | ||
32 | """ | ||
33 | return | ||
34 | |||
35 | def depleted(self, tokens_wanted, tokens_left): | ||
36 | """Bucket depletion callback | ||
37 | |||
38 | This callback is called when the token bucket has been depleted. | ||
39 | Returns a boolean indicating if the bucket should call its default | ||
40 | depletion handler. If this method returns False the token bucket will | ||
41 | not return any tokens but will also not call the default token | ||
42 | depletion handler. | ||
43 | |||
44 | This method is useful if clients want to customize depletion behavior. | ||
45 | """ | ||
46 | return True | ||
47 | |||
48 | def consumed(self, tokens_wanted, tokens_left): | ||
49 | """Bucket consumption callback | ||
50 | |||
51 | This callback is called when a client successfully consumes tokens from | ||
52 | a bucket. The return value of this method is ignored. | ||
53 | """ | ||
54 | return | ||
55 | |||
56 | |||
57 | class TokenBucket(object): | ||
58 | def __init__(self, capacity, refill_tokens, refill_rate, callbacks=None): | ||
59 | """Initialize a rate limiter | ||
60 | |||
61 | capacity | ||
62 | the number of tokens the bucket holds when completely full | ||
63 | refill_tokens | ||
64 | number of tokens to add to the bucket during each refill cycle | ||
65 | refill_rate | ||
66 | the number of seconds between token refills | ||
67 | """ | ||
68 | self.capacity = capacity | ||
69 | self.refill_tokens = refill_tokens | ||
70 | self.refill_rate = refill_rate | ||
71 | self.callbacks = callbacks or TokenBucketCallbacks() | ||
72 | |||
73 | # Depletion level at which the near_depletion callback is called. | ||
74 | # Defaults to 20% of the bucket available. Not exposed in the | ||
75 | # initializer because this should generally be good enough. | ||
76 | self.depletion_level = self.capacity / 5 | ||
77 | |||
78 | self._available_tokens = capacity | ||
79 | self._last_refill = time.time() | ||
80 | |||
81 | @classmethod | ||
82 | def creator(cls, callbacks=None): | ||
83 | """Returns a TokenBucket creator | ||
84 | |||
85 | This method is used when clients want to customize the callbacks but | ||
86 | defer class construction to the real consumer. | ||
87 | """ | ||
88 | |||
89 | def constructor(capacity, refill_tokens, refill_rate): | ||
90 | return cls(capacity, refill_tokens, refill_rate, callbacks) | ||
91 | |||
92 | return constructor | ||
93 | |||
94 | def _replentish(self): | ||
95 | now = time.time() | ||
96 | |||
97 | refill_unit = round((now - self._last_refill) / self.refill_rate) | ||
98 | if refill_unit > 0: | ||
99 | self._last_refill = now | ||
100 | self._available_tokens = min( | ||
101 | self.capacity, refill_unit * self.refill_tokens | ||
102 | ) | ||
103 | |||
104 | def _insufficient_tokens(self, tokens_wanted): | ||
105 | return | ||
106 | |||
107 | def _sufficient_tokens(self, tokens_wanted): | ||
108 | return | ||
109 | |||
110 | def consume(self, tokens=1): | ||
111 | """Consume a number of tokens from the bucket | ||
112 | |||
113 | May return a boolean indicating that sufficient tokens were available | ||
114 | for consumption. Implementations may have different behaviour when the | ||
115 | bucket is empty. This method may block or throw. | ||
116 | """ | ||
117 | self._replentish() | ||
118 | |||
119 | if self._available_tokens >= tokens: | ||
120 | self._available_tokens -= tokens | ||
121 | |||
122 | if self._available_tokens <= self.depletion_level: | ||
123 | self.callbacks.near_depletion(self._available_tokens) | ||
124 | |||
125 | self.callbacks.consumed(tokens, self._available_tokens) | ||
126 | self._sufficient_tokens(tokens) | ||
127 | |||
128 | return True | ||
129 | else: | ||
130 | if self.callbacks.depleted(tokens, self._available_tokens): | ||
131 | self._insufficient_tokens(tokens) | ||
132 | return False | ||
133 | |||
134 | |||
135 | class BlockingTokenBucket(TokenBucket): | ||
136 | """Token bucket that blocks on exhaustion | ||
137 | """ | ||
138 | |||
139 | def _insufficient_tokens(self, tokens_wanted): | ||
140 | tokens_per_second = self.refill_rate / self.refill_tokens | ||
141 | excess_tokens = tokens_wanted - self._available_tokens | ||
142 | time.sleep(tokens_per_second * excess_tokens) | ||
143 | |||
144 | |||
145 | class ThrowingTokenBucket(TokenBucket): | ||
146 | """Token bucket that throws on exhaustion | ||
147 | """ | ||
148 | |||
149 | def _insufficient_tokens(self, tokens_wanted): | ||
150 | raise RateLimitExceeded("Unable to acquire enough tokens") | ||
151 | |||
152 | |||
153 | class WarningTokenBucket(TokenBucket): | ||
154 | """Token bucket that warns on exhaustion | ||
155 | |||
156 | This token bucket doesn't enforce the rate limit and is designed to be a | ||
157 | softer policy for backwards compatability with code that was written before | ||
158 | rate limiting was added. | ||
159 | """ | ||
160 | |||
161 | def _insufficient_tokens(self, tokens_wanted): | ||
162 | warnings.warn( | ||
163 | "Pandora API rate limit exceeded!", warnings.ResourceWarning | ||
164 | ) | ||
diff --git a/pydora/player.py b/pydora/player.py index c4ce2e0..d2b1388 100644 --- a/pydora/player.py +++ b/pydora/player.py | |||
@@ -10,7 +10,9 @@ import os | |||
10 | import sys | 10 | import sys |
11 | import logging | 11 | import logging |
12 | import argparse | 12 | import argparse |
13 | from pandora import clientbuilder | 13 | from pandora.clientbuilder import PydoraConfigFileBuilder |
14 | from pandora.clientbuilder import PianobarConfigFileBuilder | ||
15 | from pandora.ratelimit import BlockingTokenBucket, TokenBucketCallbacks | ||
14 | 16 | ||
15 | from .utils import Colors, Screen | 17 | from .utils import Colors, Screen |
16 | from .audio_backend import RemoteVLC | 18 | from .audio_backend import RemoteVLC |
@@ -46,6 +48,18 @@ class PlayerCallbacks: | |||
46 | pass | 48 | pass |
47 | 49 | ||
48 | 50 | ||
51 | class PlayerTokenBucketCallbacks(TokenBucketCallbacks): | ||
52 | def __init__(self, screen): | ||
53 | self.screen = screen | ||
54 | |||
55 | def near_depletion(self, tokens_left): | ||
56 | self.screen.print_error("Near Pandora API call rate limit. Slow down!") | ||
57 | |||
58 | def depleted(self, tokens_wanted, tokens_left): | ||
59 | self.screen.print_error("Pandora API call rate exceeded. Please wait!") | ||
60 | return True | ||
61 | |||
62 | |||
49 | class PlayerApp: | 63 | class PlayerApp: |
50 | 64 | ||
51 | CMD_MAP = { | 65 | CMD_MAP = { |
@@ -75,10 +89,10 @@ class PlayerApp: | |||
75 | try: | 89 | try: |
76 | host, port = vlc_net.split(":") | 90 | host, port = vlc_net.split(":") |
77 | player = RemoteVLC(host, port, self, sys.stdin) | 91 | player = RemoteVLC(host, port, self, sys.stdin) |
78 | Screen.print_success("Using Remote VLC") | 92 | self.screen.print_success("Using Remote VLC") |
79 | return player | 93 | return player |
80 | except PlayerUnusable: | 94 | except PlayerUnusable: |
81 | Screen.print_error("Unable to connect to vlc") | 95 | self.screen.print_error("Unable to connect to vlc") |
82 | raise | 96 | raise |
83 | 97 | ||
84 | try: | 98 | try: |
@@ -99,12 +113,16 @@ class PlayerApp: | |||
99 | sys.exit(1) | 113 | sys.exit(1) |
100 | 114 | ||
101 | def get_client(self): | 115 | def get_client(self): |
116 | tb_maker = BlockingTokenBucket.creator( | ||
117 | PlayerTokenBucketCallbacks(self.screen) | ||
118 | ) | ||
119 | |||
102 | cfg_file = os.environ.get("PYDORA_CFG", "") | 120 | cfg_file = os.environ.get("PYDORA_CFG", "") |
103 | builder = clientbuilder.PydoraConfigFileBuilder(cfg_file) | 121 | builder = PydoraConfigFileBuilder(cfg_file, rate_limiter=tb_maker) |
104 | if builder.file_exists: | 122 | if builder.file_exists: |
105 | return builder.build() | 123 | return builder.build() |
106 | 124 | ||
107 | builder = clientbuilder.PianobarConfigFileBuilder() | 125 | builder = PianobarConfigFileBuilder(rate_limiter=tb_maker) |
108 | if builder.file_exists: | 126 | if builder.file_exists: |
109 | return builder.build() | 127 | return builder.build() |
110 | 128 | ||