Skip to content

Commit 4103f2c

Browse files
Add OAuth token refresh retry to upload_large chunks
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ad4c390 commit 4103f2c

2 files changed

Lines changed: 215 additions & 3 deletions

File tree

cloudinary/uploader.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from cloudinary import utils
1212
from cloudinary.api_client.execute_request import EXCEPTION_CODES
1313
from cloudinary.cache.responsive_breakpoints_cache import instance as responsive_breakpoints_cache_instance
14-
from cloudinary.exceptions import Error
14+
from cloudinary.exceptions import Error, AuthorizationRequired
1515
from cloudinary.utils import build_eager
1616

1717
try:
@@ -262,6 +262,32 @@ def upload_resource(file, **options):
262262
)
263263

264264

265+
def _upload_large_part_with_auth_retry(file, http_headers, options):
266+
"""
267+
Uploads a single chunk, recovering once from an expired OAuth token via the
268+
optional oauth_token_refresh_callback config hook. Retries the same chunk
269+
(same http_headers, so same X-Unique-Upload-Id) to resume the upload.
270+
271+
:param file: The chunk to upload.
272+
:param http_headers: Per-chunk headers, reused on retry.
273+
:param options: Upload options (must not contain an oauth_token key).
274+
:return: The result of the chunk upload API call.
275+
:rtype: dict
276+
"""
277+
# Pin the token so the value handed to the callback is the one actually sent.
278+
token = cloudinary.config().oauth_token
279+
pinned = dict(options, oauth_token=token) if token else options
280+
try:
281+
return upload_large_part(file, http_headers=http_headers, **pinned)
282+
except AuthorizationRequired:
283+
callback = cloudinary.config().oauth_token_refresh_callback
284+
if not callback:
285+
raise
286+
callback(token)
287+
# Retry with the original options so call_api re-reads the refreshed token from config.
288+
return upload_large_part(file, http_headers=http_headers, **options)
289+
290+
265291
def upload_large(file, **options):
266292
"""
267293
Uploads a large file (in chunks) to Cloudinary.
@@ -308,7 +334,9 @@ def upload_large(file, **options):
308334
"X-Unique-Upload-Id": upload_id
309335
}
310336

311-
upload_result = upload_large_part((file_name, chunk), http_headers=http_headers, **options)
337+
upload_result = _upload_large_part_with_auth_retry(
338+
(file_name, chunk), http_headers, options
339+
)
312340

313341
options["public_id"] = upload_result.get("public_id")
314342

test/test_uploader.py

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from test.helper_test import uploader_response_mock, SUFFIX, TEST_IMAGE, get_params, get_headers, TEST_ICON, TEST_DOC, \
1919
REMOTE_TEST_IMAGE, UTC, populate_large_file, TEST_UNICODE_IMAGE, get_uri, get_method, get_param, \
2020
cleanup_test_resources_by_tag, cleanup_test_transformation, cleanup_test_resources, EVAL_STR, ON_SUCCESS_STR, \
21-
URLLIB3_REQUEST, patch, retry_assertion, CldTestCase
21+
URLLIB3_REQUEST, patch, retry_assertion, CldTestCase, http_response_mock
2222
from test.test_utils import TEST_ID, TEST_FOLDER
2323

2424
MOCK_RESPONSE = uploader_response_mock()
@@ -837,6 +837,190 @@ def test_upload_large_file_io(self):
837837
self.assertEqual(resource["width"], LARGE_FILE_WIDTH)
838838
self.assertEqual(resource["height"], LARGE_FILE_HEIGHT)
839839

840+
_OAUTH_CHUNK_SIZE = 4096
841+
_OAUTH_FILE_SIZE = _OAUTH_CHUNK_SIZE * 3
842+
843+
@staticmethod
844+
def _oauth_part_response(public_id="test_public_id"):
845+
return {"public_id": public_id, "done": True}
846+
847+
def _run_upload_large_with_oauth(self, side_effect, chunk_size=None):
848+
"""Runs upload_large over a 3-chunk in-memory file with upload_large_part mocked"""
849+
chunk_size = chunk_size or self._OAUTH_CHUNK_SIZE
850+
with io.BytesIO() as temp_file:
851+
populate_large_file(temp_file, self._OAUTH_FILE_SIZE)
852+
with patch("cloudinary.uploader.upload_large_part") as part_mock:
853+
part_mock.side_effect = side_effect
854+
result = uploader.upload_large(temp_file, chunk_size=chunk_size,
855+
tags=[UNIQUE_TAG], resource_type="image")
856+
return result, part_mock
857+
858+
def test_upload_large_oauth_resume_on_mid_stream_401(self):
859+
"""Should refresh once and resume the same upload when a chunk gets a 401"""
860+
cloudinary.config(oauth_token="expired-token")
861+
862+
rejected_tokens = []
863+
864+
def refresh(rejected):
865+
rejected_tokens.append(rejected)
866+
cloudinary.config(oauth_token="fresh-token")
867+
868+
cloudinary.config().oauth_token_refresh_callback = refresh
869+
870+
calls = {"n": 0}
871+
872+
def side_effect(file, http_headers=None, **options):
873+
calls["n"] += 1
874+
if calls["n"] == 2:
875+
raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401")
876+
return self._oauth_part_response()
877+
878+
result, part_mock = self._run_upload_large_with_oauth(side_effect)
879+
880+
self.assertEqual(rejected_tokens, ["expired-token"])
881+
self.assertEqual(part_mock.call_count, 4)
882+
883+
# The retry must reuse the failed chunk's X-Unique-Upload-Id and Content-Range to resume.
884+
upload_ids = [c[1]["http_headers"]["X-Unique-Upload-Id"] for c in part_mock.call_args_list]
885+
self.assertEqual(len(set(upload_ids)), 1)
886+
ranges = [c[1]["http_headers"]["Content-Range"] for c in part_mock.call_args_list]
887+
self.assertEqual(ranges[1], ranges[2])
888+
889+
self.assertEqual(result, self._oauth_part_response())
890+
891+
def test_upload_large_oauth_resume_on_first_chunk_401(self):
892+
"""Should refresh and resume when the first chunk (no public_id yet) gets a 401"""
893+
cloudinary.config(oauth_token="expired-token")
894+
895+
refresh_calls = {"n": 0}
896+
897+
def refresh(rejected):
898+
refresh_calls["n"] += 1
899+
cloudinary.config(oauth_token="fresh-token")
900+
901+
cloudinary.config().oauth_token_refresh_callback = refresh
902+
903+
calls = {"n": 0}
904+
905+
def side_effect(file, http_headers=None, **options):
906+
calls["n"] += 1
907+
if calls["n"] == 1:
908+
self.assertIsNone(options.get("public_id"))
909+
raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401")
910+
return self._oauth_part_response()
911+
912+
result, part_mock = self._run_upload_large_with_oauth(side_effect)
913+
914+
self.assertEqual(refresh_calls["n"], 1)
915+
self.assertEqual(part_mock.call_count, 4)
916+
upload_ids = [c[1]["http_headers"]["X-Unique-Upload-Id"] for c in part_mock.call_args_list]
917+
self.assertEqual(len(set(upload_ids)), 1)
918+
self.assertEqual(result, self._oauth_part_response())
919+
920+
def test_upload_large_oauth_single_retry_then_propagate(self):
921+
"""Should retry once then propagate when the token stays rejected"""
922+
cloudinary.config(oauth_token="expired-token")
923+
924+
refresh_calls = {"n": 0}
925+
926+
def refresh(rejected):
927+
refresh_calls["n"] += 1
928+
cloudinary.config(oauth_token="still-bad-token")
929+
930+
cloudinary.config().oauth_token_refresh_callback = refresh
931+
932+
def side_effect(file, http_headers=None, **options):
933+
raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401")
934+
935+
with self.assertRaises(exceptions.AuthorizationRequired):
936+
self._run_upload_large_with_oauth(side_effect)
937+
938+
self.assertEqual(refresh_calls["n"], 1)
939+
940+
def test_upload_large_oauth_no_hook_propagates(self):
941+
"""Should propagate the first 401 with no retry when no callback is registered"""
942+
cloudinary.config(oauth_token="expired-token")
943+
944+
calls = {"n": 0}
945+
946+
def side_effect(file, http_headers=None, **options):
947+
calls["n"] += 1
948+
raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401")
949+
950+
with self.assertRaises(exceptions.AuthorizationRequired):
951+
self._run_upload_large_with_oauth(side_effect)
952+
953+
self.assertEqual(calls["n"], 1)
954+
955+
def test_upload_large_oauth_non_auth_error_not_retried(self):
956+
"""Should not retry non-auth errors even when a callback is registered"""
957+
cloudinary.config(oauth_token="some-token")
958+
959+
refresh_calls = {"n": 0}
960+
961+
def refresh(rejected):
962+
refresh_calls["n"] += 1
963+
964+
cloudinary.config().oauth_token_refresh_callback = refresh
965+
966+
for error in (exceptions.BadRequest("bad request"),
967+
exceptions.Error("Socket error: some transient failure")):
968+
refresh_calls["n"] = 0
969+
calls = {"n": 0}
970+
971+
def side_effect(file, http_headers=None, _err=error, **options):
972+
calls["n"] += 1
973+
raise _err
974+
975+
with self.assertRaises(type(error)):
976+
self._run_upload_large_with_oauth(side_effect)
977+
978+
self.assertEqual(calls["n"], 1)
979+
self.assertEqual(refresh_calls["n"], 0)
980+
981+
@patch(URLLIB3_REQUEST)
982+
def test_upload_large_oauth_rejected_token_is_the_one_sent(self, request_mock):
983+
"""Should pass the token actually sent (pinned) to the callback when it rotates per read"""
984+
token_counter = {"n": 0}
985+
986+
class RotatingConfig(cloudinary.Config):
987+
@property
988+
def oauth_token(self):
989+
token_counter["n"] += 1
990+
return "token-{}".format(token_counter["n"])
991+
992+
@oauth_token.setter
993+
def oauth_token(self, value):
994+
pass
995+
996+
rotating = RotatingConfig()
997+
rejected_seen = {}
998+
999+
def refresh(rejected):
1000+
rejected_seen["token"] = rejected
1001+
request_mock.return_value = uploader_response_mock()
1002+
1003+
rotating.oauth_token_refresh_callback = refresh
1004+
1005+
responses = {"first": True}
1006+
1007+
def request_side_effect(*args, **kwargs):
1008+
if responses["first"]:
1009+
responses["first"] = False
1010+
rejected_seen["sent"] = kwargs["headers"].get("authorization", "").replace("Bearer ", "")
1011+
return http_response_mock('{"error":{"message":"401"}}', status=401)
1012+
return uploader_response_mock()
1013+
1014+
request_mock.side_effect = request_side_effect
1015+
1016+
with patch("cloudinary.config", return_value=rotating):
1017+
with io.BytesIO() as temp_file:
1018+
populate_large_file(temp_file, self._OAUTH_CHUNK_SIZE)
1019+
uploader.upload_large(temp_file, chunk_size=self._OAUTH_CHUNK_SIZE,
1020+
tags=[UNIQUE_TAG], resource_type="image")
1021+
1022+
self.assertEqual(rejected_seen["token"], rejected_seen["sent"])
1023+
8401024
@patch(URLLIB3_REQUEST)
8411025
@unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret")
8421026
def test_upload_preset(self, mocker):

0 commit comments

Comments
 (0)