Skip to content

Commit

Permalink
RF: centralize retry-after parsing logic, add to other places where R…
Browse files Browse the repository at this point in the history
…ETRY_CODES are used

It also add additional checks/protection against odd retry-after results (too long negatives or positives),
which should address some concerns raised in prior code review
  • Loading branch information
yarikoptic committed Jan 29, 2025
1 parent 8b3f546 commit 4bbacd9
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 49 deletions.
8 changes: 8 additions & 0 deletions dandi/dandiapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
chunked,
ensure_datetime,
get_instance,
get_retry_after,
is_interactive,
is_page2_url,
joinurl,
Expand Down Expand Up @@ -236,6 +237,13 @@ def request(
)
if data is not None and hasattr(data, "seek"):
data.seek(0)
if retry_after := get_retry_after(result):
lgr.debug(

Check warning on line 241 in dandi/dandiapi.py

View check run for this annotation

Codecov / codecov/patch

dandi/dandiapi.py#L241

Added line #L241 was not covered by tests
"Sleeping for %d seconds as instructed in response "
"(in addition to tenacity imposed)",
retry_after,
)
sleep(retry_after)

Check warning on line 246 in dandi/dandiapi.py

View check run for this annotation

Codecov / codecov/patch

dandi/dandiapi.py#L246

Added line #L246 was not covered by tests
result.raise_for_status()
except Exception as e:
if isinstance(e, requests.HTTPError):
Expand Down
30 changes: 21 additions & 9 deletions dandi/dandiarchive.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
)
from .dandiapi import BaseRemoteAsset, DandiAPIClient, RemoteDandiset
from .exceptions import FailedToConnectError, NotFoundError, UnknownURLError
from .utils import get_instance
from .utils import get_instance, get_retry_after

lgr = get_logger()

Expand Down Expand Up @@ -893,14 +893,26 @@ def follow_redirect(url: str) -> str:
while True:
r = requests.head(url, allow_redirects=True)
if r.status_code in RETRY_STATUSES and i < 4:
delay = 0.1 * 10**i
lgr.warning(
"HEAD request to %s returned %d; sleeping for %f seconds and then retrying...",
url,
r.status_code,
delay,
)
sleep(delay)
retry_after = get_retry_after(r)
if retry_after is not None:
delay = retry_after

Check warning on line 898 in dandi/dandiarchive.py

View check run for this annotation

Codecov / codecov/patch

dandi/dandiarchive.py#L896-L898

Added lines #L896 - L898 were not covered by tests
else:
delay = 0.1 * 10**i
if delay:

Check warning on line 901 in dandi/dandiarchive.py

View check run for this annotation

Codecov / codecov/patch

dandi/dandiarchive.py#L900-L901

Added lines #L900 - L901 were not covered by tests
lgr.warning(
"HEAD request to %s returned %d; "
"sleeping for %f seconds and then retrying...",
url,
r.status_code,
delay,
)
sleep(delay)

Check warning on line 909 in dandi/dandiarchive.py

View check run for this annotation

Codecov / codecov/patch

dandi/dandiarchive.py#L909

Added line #L909 was not covered by tests
else:
lgr.warning(
"HEAD request to %s returned %d; retrying...",
url,
r.status_code,
)
i += 1
continue
elif r.status_code == 404:
Expand Down
53 changes: 13 additions & 40 deletions dandi/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
from collections import Counter, deque
from collections.abc import Callable, Iterable, Iterator, Sequence
from dataclasses import InitVar, dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from datetime import datetime
from enum import Enum
from functools import partial
import hashlib
Expand Down Expand Up @@ -50,6 +49,7 @@
ensure_datetime,
exclude_from_zarr,
flattened,
get_retry_after,
is_same_time,
path_is_subpath,
pluralize,
Expand Down Expand Up @@ -1121,44 +1121,17 @@ def _check_if_more_attempts_allowed(
exc,
)
return None
elif retry_after := exc.response.headers.get("Retry-After"):
if retry_after.isdigit():
sleep_amount = int(retry_after)
else:
# else if it is a datestamp like "Wed, 21 Oct 2015 07:28:00 GMT"
# we could parse it and calculate how long to sleep
try:
retry_after_date = parsedate_to_datetime(retry_after)
current_date = datetime.now(timezone.utc)
difference = retry_after_date - current_date
sleep_amount = int(difference.total_seconds())
if sleep_amount < 0:
lgr.warning(
"%s - date in Retry-After=%r is in the past (current is %r)",
path,
retry_after,
current_date,
)
except ValueError as exc_ve:
# our code or response is wrong, do not crash but issue warning
# and continue with "default" sleep logic
lgr.warning(
"%s - download failed due to response %d with non-integer or future date"
" Retry-After=%r: %s with %s upon converting assuming it is a date",
path,
exc.response.status_code,
retry_after,
exc,
exc_ve,
)
lgr.debug(
"%s - download failed due to response %d with "
"Retry-After=%d: %s, will sleep and retry",
path,
exc.response.status_code,
sleep_amount,
exc,
)
elif sleep_amount_ := get_retry_after(exc.response):
if sleep_amount_ is not None: # could be 0
sleep_amount = sleep_amount_
lgr.debug(

Check warning on line 1127 in dandi/download.py

View check run for this annotation

Codecov / codecov/patch

dandi/download.py#L1124-L1127

Added lines #L1124 - L1127 were not covered by tests
"%s - download failed due to response %d with "
"Retry-After=%d: %s, will sleep and retry",
path,
exc.response.status_code,
sleep_amount,
exc,
)
if sleep_amount < 0:

Check warning on line 1135 in dandi/download.py

View check run for this annotation

Codecov / codecov/patch

dandi/download.py#L1135

Added line #L1135 was not covered by tests
# it was not Retry-after set, so we come up with random duration to sleep
sleep_amount = random.random() * 5 * attempt

Check warning on line 1137 in dandi/download.py

View check run for this annotation

Codecov / codecov/patch

dandi/download.py#L1137

Added line #L1137 was not covered by tests
Expand Down
63 changes: 63 additions & 0 deletions dandi/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from bisect import bisect
from collections.abc import Iterable, Iterator
import datetime
from email.utils import parsedate_to_datetime
from functools import lru_cache
from importlib.metadata import version as importlib_version
import inspect
Expand Down Expand Up @@ -890,3 +891,65 @@ def joinurl(base: str, path: str) -> str:
return path
else:
return base.rstrip("/") + "/" + path.lstrip("/")


def get_retry_after(response: requests.Response) -> Optional[int]:
"""If provided and parsed ok, returns duration in seconds to sleep before retry.
If not provided in the response header `Retry-After`, would
return None.
If parsing fails, or provided date/sleep does not make sense
since either too far in the past (over 2 seconds) or in the future
(over a week), would return None.
"""
if_unparsable = None
retry_after = response.headers.get("Retry-After")
if retry_after is None:
return None
sleep_amount: int | None
if retry_after.isdecimal():
sleep_amount = int(retry_after)

Check warning on line 911 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L910-L911

Added lines #L910 - L911 were not covered by tests
else:
# else if it is a datestamp like "Wed, 21 Oct 2015 07:28:00 GMT"
# we could parse it and calculate how long to sleep
current_date = datetime.datetime.now(datetime.timezone.utc)
try:
retry_after_date = parsedate_to_datetime(retry_after)
except (ValueError, TypeError) as exc_ve:

Check warning on line 918 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L915-L918

Added lines #L915 - L918 were not covered by tests
# our code or response is wrong, do not crash but issue warning
# and continue with "if_unparsable" sleep logic
lgr.warning(

Check warning on line 921 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L921

Added line #L921 was not covered by tests
"response %d has incorrect date in Retry-After=%r: %s. " "Returning %r",
response.status_code,
retry_after,
exc_ve,
)
sleep_amount = if_unparsable

Check warning on line 927 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L927

Added line #L927 was not covered by tests
else:
difference = retry_after_date - current_date
sleep_amount = int(difference.total_seconds())

Check warning on line 930 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L929-L930

Added lines #L929 - L930 were not covered by tests

if sleep_amount:
if -2 < sleep_amount < 0:

Check warning on line 933 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L932-L933

Added lines #L932 - L933 were not covered by tests
# allow for up to a few seconds delay in us receiving/parsing etc
# but otherwise assume abnormality and just return if_unparsable
sleep_amount = 0
elif sleep_amount < 0:
lgr.warning(

Check warning on line 938 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L936-L938

Added lines #L936 - L938 were not covered by tests
"date in Retry-After=%r is in the past (current is %r). "
"Returning %r",
retry_after,
current_date,
if_unparsable,
)
sleep_amount = if_unparsable
elif sleep_amount > 7 * 24 * 60 * 60: # week
lgr.warning(

Check warning on line 947 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L945-L947

Added lines #L945 - L947 were not covered by tests
"date in Retry-After=%r is over a week in the future (current is %r). "
"Returning %r",
retry_after,
current_date,
if_unparsable,
)
sleep_amount = if_unparsable
return sleep_amount

Check warning on line 955 in dandi/utils.py

View check run for this annotation

Codecov / codecov/patch

dandi/utils.py#L954-L955

Added lines #L954 - L955 were not covered by tests

0 comments on commit 4bbacd9

Please sign in to comment.