diff options
-rw-r--r-- | docs/index.rst | 27 | ||||
-rw-r--r-- | mastodon/Mastodon.py | 86 |
2 files changed, 87 insertions, 26 deletions
diff --git a/docs/index.rst b/docs/index.rst index e7c9366..02676a4 100644 --- a/docs/index.rst +++ b/docs/index.rst | |||
@@ -39,6 +39,33 @@ as a single python module. By default, it talks to the | |||
39 | `Mastodon flagship instance`_, but it can be set to talk to any | 39 | `Mastodon flagship instance`_, but it can be set to talk to any |
40 | node running Mastodon. | 40 | node running Mastodon. |
41 | 41 | ||
42 | A note about rate limits | ||
43 | ------------------------ | ||
44 | Mastodons API rate limits per IP. Mastodon.py has three modes for dealing | ||
45 | with rate limiting that you can pass to the constructor, "throw", "wait" | ||
46 | and "pace", "wait" being the default. | ||
47 | |||
48 | In "throw" mode, Mastodon.py makes no attempt to stick to rate limits. When | ||
49 | a request hits the rate limit, it simply throws a MastodonRateLimitError. This is | ||
50 | for applications that need to handle all rate limiting themselves (i.e. interactive apps), | ||
51 | or applications wanting to use Mastodon.py in a multi-threaded context ("wait" and "pace" | ||
52 | modes are not thread safe). | ||
53 | |||
54 | In "wait" mode, once a request hits the rate limit, Mastodon.py will wait until | ||
55 | the rate limit resets and then try again, until the request succeeds or an error | ||
56 | is encountered. This mode is for applications that would rather just not worry about rate limits | ||
57 | much, don't poll the api all that often, and are okay with a call sometimes just taking | ||
58 | a while. | ||
59 | |||
60 | In "pace" mode, Mastodon.py will delay each new request after the first one such that, | ||
61 | if requests were to continue at the same rate, only a certain fraction (set in the | ||
62 | constructor as ratelimit_pacefactor) of the rate limit will be used up. The fraction can | ||
63 | be (and by default, is) greater than one. If the rate limit is hit, "pace" behaves like | ||
64 | "wait". This mode is probably the most advanced one and allows you to just poll in | ||
65 | a loop without ever sleeping at all yourself. It is for applications that would rather | ||
66 | just pretend there is no such thing as a rate limit and are fine with sometimes not | ||
67 | being very interactive. | ||
68 | |||
42 | A note about IDs | 69 | A note about IDs |
43 | ---------------- | 70 | ---------------- |
44 | Mastodons API uses IDs in several places: User IDs, Toot IDs, ... | 71 | Mastodons API uses IDs in several places: User IDs, Toot IDs, ... |
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index bc1c52b..bb16d95 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -7,7 +7,10 @@ import mimetypes | |||
7 | import time | 7 | import time |
8 | import random | 8 | import random |
9 | import string | 9 | import string |
10 | from datetime import datetime | 10 | import pytz |
11 | import datetime | ||
12 | import dateutil | ||
13 | import dateutil.parser | ||
11 | 14 | ||
12 | class Mastodon: | 15 | class Mastodon: |
13 | """ | 16 | """ |
@@ -62,12 +65,12 @@ class Mastodon: | |||
62 | ### | 65 | ### |
63 | # Authentication, including constructor | 66 | # Authentication, including constructor |
64 | ### | 67 | ### |
65 | def __init__(self, client_id, client_secret = None, access_token = None, api_base_url = __DEFAULT_BASE_URL, debug_requests = False, ratelimit_method = "wait", ratelimit_pacefactor = 0.9): | 68 | def __init__(self, client_id, client_secret = None, access_token = None, api_base_url = __DEFAULT_BASE_URL, debug_requests = False, ratelimit_method = "wait", ratelimit_pacefactor = 1.1): |
66 | """ | 69 | """ |
67 | Create a new API wrapper instance based on the given client_secret and client_id. If you | 70 | Create a new API wrapper instance based on the given client_secret and client_id. If you |
68 | give a client_id and it is not a file, you must also give a secret. | 71 | give a client_id and it is not a file, you must also give a secret. |
69 | 72 | ||
70 | You can also directly specify an access_token, directly or as a file. | 73 | You can also specify an access_token, directly or as a file (as written by log_in). |
71 | 74 | ||
72 | Mastodon.py can try to respect rate limits in several ways, controlled by ratelimit_method. | 75 | Mastodon.py can try to respect rate limits in several ways, controlled by ratelimit_method. |
73 | "throw" makes functions throw a MastodonRatelimitError when the rate | 76 | "throw" makes functions throw a MastodonRatelimitError when the rate |
@@ -92,7 +95,7 @@ class Mastodon: | |||
92 | self.ratelimit_reset = time.time() | 95 | self.ratelimit_reset = time.time() |
93 | self.ratelimit_remaining = 150 | 96 | self.ratelimit_remaining = 150 |
94 | self.ratelimit_lastcall = time.time() | 97 | self.ratelimit_lastcall = time.time() |
95 | self.ratelimit_pacefactor = 0.9 | 98 | self.ratelimit_pacefactor = ratelimit_pacefactor |
96 | 99 | ||
97 | if os.path.isfile(self.client_id): | 100 | if os.path.isfile(self.client_id): |
98 | with open(self.client_id, 'r') as secret_file: | 101 | with open(self.client_id, 'r') as secret_file: |
@@ -426,15 +429,26 @@ class Mastodon: | |||
426 | ### | 429 | ### |
427 | # Internal helpers, dragons probably | 430 | # Internal helpers, dragons probably |
428 | ### | 431 | ### |
429 | def __api_request(self, method, endpoint, params = {}, files = {}, do_ratelimiting = True): | 432 | def __datetime_to_epoch(self, date_time): |
430 | """ | 433 | """ |
431 | Internal API request helper. | 434 | Converts a python datetime to unix epoch, accounting for |
435 | time zones and such. | ||
432 | 436 | ||
433 | TODO FIXME: time.time() does not match server time neccesarily. Using the time from the request | 437 | Assumes UTC if timezone is not given. |
434 | would be correct. | 438 | """ |
439 | date_time_utc = None | ||
440 | if date_time.tzinfo == None: | ||
441 | date_time_utc = date_time.replace(tzinfo = pytz.utc) | ||
442 | else: | ||
443 | date_time_utc = date_time.astimezone(pytz.utc) | ||
435 | 444 | ||
436 | TODO FIXME: Date parsing can fail. Should probably use a proper "date parsing" module rather than | 445 | epoch_utc = datetime.datetime.utcfromtimestamp(0).replace(tzinfo = pytz.utc) |
437 | rely on the server to return the right thing. | 446 | |
447 | return (date_time_utc - epoch_utc).total_seconds() | ||
448 | |||
449 | def __api_request(self, method, endpoint, params = {}, files = {}, do_ratelimiting = True): | ||
450 | """ | ||
451 | Internal API request helper. | ||
438 | """ | 452 | """ |
439 | response = None | 453 | response = None |
440 | headers = None | 454 | headers = None |
@@ -445,6 +459,8 @@ class Mastodon: | |||
445 | if self.ratelimit_remaining == 0: | 459 | if self.ratelimit_remaining == 0: |
446 | to_next = self.ratelimit_reset - time.time() | 460 | to_next = self.ratelimit_reset - time.time() |
447 | if to_next > 0: | 461 | if to_next > 0: |
462 | # As a precaution, never sleep longer than 5 minutes | ||
463 | to_next = min(to_next, 5 * 60) | ||
448 | time.sleep(to_next) | 464 | time.sleep(to_next) |
449 | else: | 465 | else: |
450 | time_waited = time.time() - self.ratelimit_lastcall | 466 | time_waited = time.time() - self.ratelimit_lastcall |
@@ -452,7 +468,9 @@ class Mastodon: | |||
452 | remaining_wait = time_wait - time_waited | 468 | remaining_wait = time_wait - time_waited |
453 | 469 | ||
454 | if remaining_wait > 0: | 470 | if remaining_wait > 0: |
455 | time.sleep(remaining_wait * self.ratelimit_pacefactor) | 471 | to_next = remaining_wait / self.ratelimit_pacefactor |
472 | to_next = min(to_next, 5 * 60) | ||
473 | time.sleep(to_next) | ||
456 | 474 | ||
457 | # Generate request headers | 475 | # Generate request headers |
458 | if self.access_token != None: | 476 | if self.access_token != None: |
@@ -503,21 +521,34 @@ class Mastodon: | |||
503 | raise MastodonAPIError("Could not parse response as JSON, respose code was " + str(response_object.status_code)) | 521 | raise MastodonAPIError("Could not parse response as JSON, respose code was " + str(response_object.status_code)) |
504 | 522 | ||
505 | # Handle rate limiting | 523 | # Handle rate limiting |
506 | if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting: | 524 | try: |
507 | self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining']) | 525 | if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting: |
508 | self.ratelimit_limit = int(response_object.headers['X-RateLimit-Limit']) | 526 | self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining']) |
509 | self.ratelimit_reset = (datetime.strptime(response_object.headers['X-RateLimit-Reset'], "%Y-%m-%dT%H:%M:%S.%fZ") - datetime(1970, 1, 1)).total_seconds() | 527 | self.ratelimit_limit = int(response_object.headers['X-RateLimit-Limit']) |
510 | self.ratelimit_lastcall = time.time() | 528 | |
511 | 529 | ratelimit_reset_datetime = dateutil.parser.parse(response_object.headers['X-RateLimit-Reset']) | |
512 | if "error" in response and response["error"] == "Throttled": | 530 | self.ratelimit_reset = self.__datetime_to_epoch(ratelimit_reset_datetime) |
513 | if self.ratelimit_method == "throw": | 531 | |
514 | raise MastodonRatelimitError("Hit rate limit.") | 532 | # Adjust server time to local clock |
515 | 533 | server_time_datetime = dateutil.parser.parse(response_object.headers['Date']) | |
516 | if self.ratelimit_method == "wait" or self.ratelimit_method == "pace": | 534 | server_time = self.__datetime_to_epoch(server_time_datetime) |
517 | to_next = self.ratelimit_reset - time.time() | 535 | server_time_diff = time.time() - server_time |
518 | if to_next > 0: | 536 | self.ratelimit_reset += server_time_diff |
519 | time.sleep(to_next) | 537 | self.ratelimit_lastcall = time.time() |
520 | request_complete = False | 538 | |
539 | if "error" in response and response["error"] == "Throttled": | ||
540 | if self.ratelimit_method == "throw": | ||
541 | raise MastodonRatelimitError("Hit rate limit.") | ||
542 | |||
543 | if self.ratelimit_method == "wait" or self.ratelimit_method == "pace": | ||
544 | to_next = self.ratelimit_reset - time.time() | ||
545 | if to_next > 0: | ||
546 | # As a precaution, never sleep longer than 5 minutes | ||
547 | to_next = min(to_next, 5 * 60) | ||
548 | time.sleep(to_next) | ||
549 | request_complete = False | ||
550 | except: | ||
551 | raise MastodonRatelimitError("Rate limit time calculations failed.") | ||
521 | 552 | ||
522 | return response | 553 | return response |
523 | 554 | ||
@@ -547,6 +578,9 @@ class Mastodon: | |||
547 | class MastodonIllegalArgumentError(ValueError): | 578 | class MastodonIllegalArgumentError(ValueError): |
548 | pass | 579 | pass |
549 | 580 | ||
581 | class MastodonFileNotFoundError(IOError): | ||
582 | pass | ||
583 | |||
550 | class MastodonNetworkError(IOError): | 584 | class MastodonNetworkError(IOError): |
551 | pass | 585 | pass |
552 | 586 | ||