Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion airtable/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .airtable import Airtable # noqa
from .airtable import Airtable, RateLimitRetry # noqa
68 changes: 62 additions & 6 deletions airtable/airtable.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,24 +186,39 @@ def _request(self, method, url, params=None, json_data=None):
)
return self._process_response(response)

def _retriable_request(self, method, url, params=None, json_data=None):
retry = getattr(self, "_retry", None)
if not retry:
return self._request(method, url, params=None, json_data=None)
for n in range(retry.retry_count):
try:
return self._request(method, url, params=None, json_data=None)
except requests.exceptions.HTTPError as exc:
if exc.response.status_code == 429:
retry.wait()
continue
raise

def _get(self, url, **params):
processed_params = self._process_params(params)
return self._request("get", url, params=processed_params)
return self._retriable_request("get", url, params=processed_params)

def _post(self, url, json_data):
return self._request("post", url, json_data=json_data)
return self._retriable_request("post", url, json_data=json_data)

def _put(self, url, json_data):
return self._request("put", url, json_data=json_data)
return self._retriable_request("put", url, json_data=json_data)

def _patch(self, url, json_data):
return self._request("patch", url, json_data=json_data)
return self._retriable_request("patch", url, json_data=json_data)

def _delete(self, url):
return self._request("delete", url)
return self._retriable_request("delete", url)

def _delete_batch(self, record_ids):
return self._request("delete", self.url_table, params={"records": record_ids})
return self._retriable_request(
"delete", self.url_table, params={"records": record_ids}
)

def get(self, record_id):
"""
Expand Down Expand Up @@ -579,3 +594,44 @@ def batch_delete(self, record_ids):

def __repr__(self):
return "<Airtable table:{}>".format(self.table_name)


class RateLimitRetry:
"""
RateLimitRetry
***********************
"""

def __init__(self, airtable, retry_count=3, wait_seconds=30):
"""
Allow to retry if response is Rate Limit Error.
All outbound requests will retry `retry_count` times.
Only HTTPError with status code 429 are retried.

>>> airtable = Airtable(...)
>>> for record in records:
... with RateLimitRetry(airtable):
... airtable.insert(record)

Args:
airtable(``Airtable``): Airtable instance.

Keyword Args:
retry_count(``int``, optional): Number of times it should retry.
Default is `3` times.
wait_seconds(``int``, optional): Wait time in seconds in between each retry.
Default is `3` seconds.

"""
self.airtable = airtable
self.retry_count = retry_count
self.wait_seconds = wait_seconds

def __enter__(self):
self.airtable._retry = self

def __exit__(self, *exc):
del self.airtable._retry

def wait(self):
time.sleep(self.wait_seconds)
8 changes: 7 additions & 1 deletion docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ Overview

_______________________________________________

Class API
Airtable
*********

.. autoclass:: airtable.Airtable
:members:

RateLimitRetry
**************

.. autoclass:: airtable.RateLimitRetry
:members:

_______________________________________________

Source Code
Expand Down
37 changes: 37 additions & 0 deletions tests/test_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import pytest
from unittest import mock

from airtable import RateLimitRetry
from requests.exceptions import HTTPError


@pytest.fixture
def rate_error():
exc = HTTPError()
exc.response = mock.MagicMock()
exc.response.status_code = 429
return exc


@mock.patch("airtable.airtable.Airtable._request")
@mock.patch("airtable.airtable.time.sleep")
def test_retry(m_sleep, m_request, table, rate_error):
# Raise Rate Error when airtable._request is called
m_request.side_effect = rate_error

with RateLimitRetry(table, wait_seconds=31):
table.insert({})

assert m_request.call_count == 3
assert m_sleep.call_count == 3
m_sleep.assert_called_with(31)


@mock.patch("airtable.airtable.Airtable._request")
@mock.patch("airtable.airtable.time.sleep")
def test_without_retry(m_sleep, m_request, table, rate_error):
m_request.side_effect = rate_error
table.insert({})

assert m_request.call_count == 1
assert not m_sleep.called