Skip to content

Commit 2de20d5

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 6d77606 commit 2de20d5

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()
@@ -832,6 +832,190 @@ def test_upload_large_file_io(self):
832832
self.assertEqual(resource["width"], LARGE_FILE_WIDTH)
833833
self.assertEqual(resource["height"], LARGE_FILE_HEIGHT)
834834

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

0 commit comments

Comments
 (0)