Skip to content

Retry-After header is ignored because response headers are stored in a case-sensitive dict #1582

Description

@kazuki1123
  • I have checked that the SDK documentation doesn't solve my issue.
  • I have checked that the API documentation doesn't solve my issue.
  • I have searched the Box Developer Forums and my issue isn't already reported.
  • I have searched Issues in this repo and my issue isn't already reported.

Description of the Issue

BoxRetryStrategy looks up the Retry-After header with exact casing, but response headers are stored in a plain dict that preserves whatever casing the server sent. Box's API returns this header lowercased (retry-after), so the lookup never matches and the SDK silently falls back to its own exponential backoff, discarding the wait time the server asked for.

BoxNetworkClient converts requests' CaseInsensitiveDict into a plain dict (both occurrences):

# box_sdk_gen/networking/box_network_client.py
headers=dict(response.network_response.headers),

BoxRetryStrategy then queries that dict with capitalized keys:

# box_sdk_gen/networking/retries.py
retry_after_header: Optional[str] = (
    fetch_response.headers.get('Retry-After')
    if 'Retry-After' in fetch_response.headers
    else None
)

Both the in check and the .get() are case-sensitive, so neither matches retry-after.

This affects two code paths in retries.py:

  1. retry_after() -- the delay requested by the server is discarded and replaced with the SDK's own exponential backoff.
  2. should_retry() -- is_accepted_with_retry_after never becomes True, so the 202 Accepted + Retry-After retry path does not trigger either.

Steps to Reproduce

The mismatch can be demonstrated without any Box credentials:

from requests.structures import CaseInsensitiveDict

# What requests hands to the SDK (Box sends the header lowercased)
headers = CaseInsensitiveDict({'retry-after': '90'})
print(headers.get('Retry-After'))     # '90'  - fine while it is a CaseInsensitiveDict

# What the SDK actually stores and queries
plain = dict(headers)                 # box_network_client.py
print(list(plain.keys()))             # ['retry-after']
print('Retry-After' in plain)         # False   <-- the guard in retries.py
print(plain.get('Retry-After'))       # None    <-- the lookup in retries.py

End to end: trigger any response that carries retry-after (for example a 503 while creating many folders in quick succession) and observe that the gap between retries follows 2**attempt * retry_base_interval * random(0.5, 1.5) rather than the value the server sent.

Expected Behavior

The Retry-After value is honored regardless of header casing, and a 202 Accepted carrying Retry-After is retried as intended.

Actual Behavior

Retry-After is never found. With the default BoxRetryStrategy (max_attempts=5, retry_base_interval=1), a server asking for a 90 second pause is instead retried after roughly 1-3s, 2-6s, 4-12s and 8-24s, exhausting every attempt in about 15-45 seconds. The client retries hardest at exactly the moment the service asked it to back off.

Error Message, Including Stack Trace

Redacted response from a POST /2.0/folders that failed this way. Note the lowercase retry-after in the header dump the SDK itself prints:

Message: 503 Service is temporarily unavailable
Request:
    Method: POST
    URL: https://api.box.com/2.0/folders
Response:
    Status code: 503
    Headers:
{       'cache-control': 'no-cache, no-store',
        'content-type': 'application/json',
        'retry-after': '90',
        'x-envoy-upstream-service-time': '5181'}
    Code: unavailable

Suggested Fix

Either keep the headers case-insensitive when building FetchResponse:

headers=CaseInsensitiveDict(response.network_response.headers),

or make the lookup itself case-insensitive, which avoids depending on the network client implementation:

retry_after_header = next(
    (v for k, v in fetch_response.headers.items() if k.lower() == 'retry-after'),
    None,
)

As a side note, float(retry_after_header) raises ValueError if the header arrives in the HTTP-date form that RFC 9110 also permits.

Versions Used

Python SDK: reproduced with box-sdk-gen 1.17.0, where the same lookup exists in a simpler form (fetch_response.headers.get('Retry-After') without the in guard). The code quoted above is from this repository's main, which has the same defect.
Python: 3.10.8

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions