This is an automated email from the ASF dual-hosted git repository.

netudima pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-ccm.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 6bf561e  Download releases from dlcdn.apache.org before 
archive.apache.org, and time out stalled downloads
6bf561e is described below

commit 6bf561eef441459d8c4700e940280ca768aa4864
Author: Dmitry Konstantinov <[email protected]>
AuthorDate: Sat Oct 24 17:40:23 2026 +0100

    Download releases from dlcdn.apache.org before archive.apache.org, and time 
out stalled downloads
    
    Try dlcdn.apache.org, then downloads.apache.org, then archive.apache.org; a 
404 moves on to the next mirror.
    Use a 45s socket timeout and make up to 3 attempts per url on timeouts, 
connection errors,
    truncated responses and HTTP 5xx/408/429.
    
    patch by Dmitry Konstantinov; reviewed by Michael Semb Wever for 
CASSANDRA-21805
    
    Assisted-By: Claude Code (Opus 5.5)
---
 ccmlib/repository.py     | 119 +++++++++++++++++++---------
 tests/test_repository.py | 197 +++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 279 insertions(+), 37 deletions(-)

diff --git a/ccmlib/repository.py b/ccmlib/repository.py
index 62e9c0f..55af470 100644
--- a/ccmlib/repository.py
+++ b/ccmlib/repository.py
@@ -43,10 +43,15 @@ from ccmlib import common
 from ccmlib.common import (ArgumentError, CCMError,
                            update_java_version, get_default_path, 
get_jdk_version_int,
                            platform_binary, rmdirs, validate_install_dir)
-from six.moves import urllib
+from six.moves import http_client, urllib
 
 
 ARCHIVE = "http://archive.apache.org/dist/cassandra";
+# dlcdn and downloads only keep the latest releases; archive keeps all of them 
but is slow and throttled
+MIRRORS = ["https://dlcdn.apache.org/cassandra";, 
"https://downloads.apache.org/cassandra";, ARCHIVE]
+DOWNLOAD_TIMEOUT = 45  # seconds a download may wait for data before the 
attempt is abandoned
+DOWNLOAD_ATTEMPTS = 3  # attempts per url
+DOWNLOAD_RETRY_DELAY = 5  # seconds, multiplied by the attempt number
 GIT_REPO = "https://github.com/apache/cassandra.git";
 GITHUB_REPO = "https://github.com/apache/cassandra";
 GITHUB_TAGS = "https://api.github.com/repos/apache/cassandra/git/refs/tags";
@@ -68,7 +73,7 @@ def setup(version, verbose=False):
             download_version(version, verbose=verbose, binary=True)
             cdir = version_directory(version)
         except Exception as e:
-            # If we failed to download from ARCHIVE,
+            # If we failed to download from the mirrors,
             # then we build from source from the git repo,
             # as it is more reliable.
             # We don't do this if binary: or source: were
@@ -283,17 +288,18 @@ def download_version(version, url=None, verbose=False, 
binary=False):
     if binary == True, download precompiled tarball, otherwise build from 
source tarball.
     """
 
-    archive_url = ARCHIVE
+    repositories = MIRRORS
     if CCM_CONFIG.has_option('repositories', 'cassandra'):
-        archive_url = CCM_CONFIG.get('repositories', 'cassandra')
+        repositories = [CCM_CONFIG.get('repositories', 'cassandra')]
 
-    if binary:
-        archive_url = "%s/%s/apache-cassandra-%s-bin.tar.gz" % (archive_url, 
version, version) if url is None else url
+    if url is not None:
+        urls = [url]
     else:
-        archive_url = "%s/%s/apache-cassandra-%s-src.tar.gz" % (archive_url, 
version, version) if url is None else url
+        kind = 'bin' if binary else 'src'
+        urls = ["%s/%s/apache-cassandra-%s-%s.tar.gz" % (r, version, version, 
kind) for r in repositories]
     _, target = tempfile.mkstemp(suffix=".tar.gz", prefix="ccm-")
     try:
-        __download(archive_url, target, show_progress=verbose)
+        __download_from_first(urls, target, show_progress=verbose)
         common.info("Extracting {} as version {} ...".format(target, version))
         tar = tarfile.open(target)
         dir = tar.next().name.split("/")[0]  # pylint: disable=all
@@ -477,6 +483,21 @@ def get_tagged_version_numbers(series='stable'):
         raise AssertionError("unknown release series: 
{series}".format(series=series))
 
 
+def __download_from_first(urls, target, show_progress=False):
+    """Download the first of urls that is available, moving on to the next one 
when a url is missing or keeps failing"""
+    for i, url in enumerate(urls):
+        try:
+            __download(url, target, show_progress=show_progress)
+            return
+        except (EnvironmentError, http_client.HTTPException) as e:
+            if i == len(urls) - 1:
+                raise
+            if isinstance(e, urllib.error.HTTPError) and e.code == 404:
+                common.debug("{} not found, trying {}".format(url, urls[i + 
1]))
+            else:
+                common.warning("Downloading {} failed: {}, trying 
{}".format(url, e, urls[i + 1]))
+
+
 def __download(url, target, username=None, password=None, show_progress=False):
     if username is not None:
         password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
@@ -485,36 +506,60 @@ def __download(url, target, username=None, password=None, 
show_progress=False):
         opener = urllib.request.build_opener(handler)
         urllib.request.install_opener(opener)  # pylint: disable=E1121
 
-    u = urllib.request.urlopen(url)
-    f = open(target, 'wb')
-    meta = u.info()
-    file_size = int(meta.get("Content-Length"))
-    common.info("Downloading {} to {} ({:.3f}MB)".format(url, target, 
float(file_size) / (1024 * 1024)))
-
-    file_size_dl = 0
-    block_sz = 8192
-    status = None
-    attempts = 0
-    while file_size_dl < file_size:
-        buffer = u.read(block_sz)
-        if not buffer:
-            attempts = attempts + 1
-            if attempts >= 5:
-                raise CCMError("Error downloading file (nothing read after {} 
attempts, downloded only {} of {} bytes)".format(attempts, file_size_dl, 
file_size))
-            time.sleep(0.5 * attempts)
-            continue
+    # local files (file://) are not retried
+    attempts = DOWNLOAD_ATTEMPTS if url.startswith(('http://', 'https://')) 
else 1
+    for attempt in range(1, attempts + 1):
+        try:
+            __download_once(url, target, show_progress)
+            return
+        except urllib.error.HTTPError as e:
+            # a missing file or a refused request will not change on retry
+            if e.code < 500 and e.code not in (408, 429):
+                raise
+            error = e
+        except urllib.error.URLError as e:
+            # neither will a certificate that cannot be verified
+            if getattr(e.reason, 'reason', None) == 
'CERTIFICATE_VERIFY_FAILED':
+                raise
+            error = e
+        # EnvironmentError covers URLError, socket errors and timeouts on both 
Python 2 and 3
+        except (EnvironmentError, http_client.HTTPException) as e:
+            error = e
+        if attempt == attempts:
+            raise error
+        common.warning("Downloading {} failed (attempt {} of {}): 
{}".format(url, attempt, attempts, error))
+        time.sleep(DOWNLOAD_RETRY_DELAY * attempt)
+
+
+def __download_once(url, target, show_progress):
+    # the timeout bounds every socket operation, so a stalled connection fails 
instead of blocking forever
+    u = urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT)
+    try:
+        content_length = u.info().get("Content-Length")
+        file_size = int(content_length) if content_length is not None else None
+        if file_size is not None:
+            common.info("Downloading {} to {} ({:.3f}MB)".format(url, target, 
float(file_size) / (1024 * 1024)))
         else:
-            attempts = 0
-
-        file_size_dl += len(buffer)
-        f.write(buffer)
-        if show_progress:
-            status = r"%10d  [%3.2f%%]" % (file_size_dl, file_size_dl * 100. / 
file_size)
-            status = chr(8) * (len(status) + 1) + status
-            print_(status, end='')
-
-    f.close()
-    u.close()
+            common.info("Downloading {} to {}".format(url, target))
+
+        file_size_dl = 0
+        block_sz = 8192
+        with open(target, 'wb') as f:
+            while True:
+                buffer = u.read(block_sz)
+                if not buffer:
+                    break
+                file_size_dl += len(buffer)
+                f.write(buffer)
+                if show_progress and file_size:
+                    status = r"%10d  [%3.2f%%]" % (file_size_dl, file_size_dl 
* 100. / file_size)
+                    status = chr(8) * (len(status) + 1) + status
+                    print_(status, end='')
+    finally:
+        u.close()
+
+    if file_size is not None and file_size_dl < file_size:
+        raise IOError("Connection closed after {} of {} 
bytes".format(file_size_dl, file_size))
 
 
 def __get_dir():
diff --git a/tests/test_repository.py b/tests/test_repository.py
new file mode 100644
index 0000000..4b1031c
--- /dev/null
+++ b/tests/test_repository.py
@@ -0,0 +1,197 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import configparser
+import io
+import os
+import ssl
+import tarfile
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+import pytest
+from six.moves import urllib
+
+from ccmlib import repository
+
+PAYLOAD = bytes(range(256)) * 256
+STALL_SECONDS = 5
+SSL_DIR = os.path.join(os.path.dirname(__file__), '..', 'ssl')
+
+
+class _MirrorHandler(BaseHTTPRequestHandler):
+    """Serves the payload, misbehaving as the first path segment says"""
+
+    def do_GET(self):
+        requests = self.server.requests
+        requests.append(self.path)
+        behaviour = self.path.split('/')[1]
+        first = requests.count(self.path) == 1
+        payload = self.server.payload
+
+        if behaviour == 'missing':
+            self.send_error(404)
+            return
+        if behaviour == 'error-once' and first:
+            self.send_error(500)
+            return
+
+        self.send_response(200)
+        self.send_header('Content-Length', str(len(payload)))
+        self.end_headers()
+        if behaviour == 'stall-always' or (behaviour == 'stall-once' and 
first):
+            self.wfile.write(payload[:len(payload) // 2])
+            self.wfile.flush()
+            time.sleep(STALL_SECONDS)
+        elif behaviour == 'truncate-once' and first:
+            # HTTP/1.0: the connection closes when the handler returns
+            self.wfile.write(payload[:len(payload) // 2])
+        else:
+            self.wfile.write(payload)
+
+    def log_message(self, *args):
+        pass
+
+
+class _MirrorServer(ThreadingHTTPServer):
+    block_on_close = False  # do not wait for stalled handlers
+
+    def __init__(self, context=None):
+        ThreadingHTTPServer.__init__(self, ('127.0.0.1', 0), _MirrorHandler)
+        if context is not None:
+            self.socket = context.wrap_socket(self.socket, server_side=True)
+        self.connections = 0
+        self.requests = []
+        self.payload = PAYLOAD
+        self.base = '{}://127.0.0.1:{}'.format('https' if context else 'http', 
self.server_address[1])
+
+    def get_request(self):
+        # counted before the TLS handshake, which never reaches the handler 
when it fails
+        self.connections += 1
+        return ThreadingHTTPServer.get_request(self)
+
+
+def _serve(server):
+    threading.Thread(target=server.serve_forever, daemon=True).start()
+    yield server
+    server.shutdown()
+    server.server_close()
+
+
[email protected]
+def mirror():
+    yield from _serve(_MirrorServer())
+
+
[email protected]
+def untrusted_mirror():
+    # self-signed and long expired, so no client verifies it
+    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+    context.load_cert_chain(os.path.join(SSL_DIR, 'client_cert.pem'), 
os.path.join(SSL_DIR, 'client_key.pem'))
+    yield from _serve(_MirrorServer(context))
+
+
[email protected](autouse=True)
+def fast_retries(monkeypatch):
+    monkeypatch.setattr(repository, 'DOWNLOAD_TIMEOUT', 0.5)
+    monkeypatch.setattr(repository, 'DOWNLOAD_RETRY_DELAY', 0)
+
+
+def _read(path):
+    with open(path, 'rb') as f:
+        return f.read()
+
+
+def test_missing_url_falls_through_to_next_without_retry(mirror, tmp_path):
+    target = str(tmp_path / 'download')
+    repository.__download_from_first([mirror.base + '/missing/f', mirror.base 
+ '/ok/f'], target)
+
+    assert _read(target) == PAYLOAD
+    assert mirror.requests == ['/missing/f', '/ok/f']
+
+
+def test_stalled_connection_times_out_and_is_retried(mirror, tmp_path):
+    target = str(tmp_path / 'download')
+    start = time.time()
+    repository.__download_from_first([mirror.base + '/stall-once/f'], target)
+
+    assert _read(target) == PAYLOAD
+    assert mirror.requests == ['/stall-once/f'] * 2
+    assert time.time() - start < STALL_SECONDS
+
+
+def test_truncated_response_is_retried(mirror, tmp_path):
+    target = str(tmp_path / 'download')
+    repository.__download_from_first([mirror.base + '/truncate-once/f'], 
target)
+
+    assert _read(target) == PAYLOAD
+    assert mirror.requests == ['/truncate-once/f'] * 2
+
+
+def test_server_error_is_retried(mirror, tmp_path):
+    target = str(tmp_path / 'download')
+    repository.__download_from_first([mirror.base + '/error-once/f'], target)
+
+    assert _read(target) == PAYLOAD
+    assert mirror.requests == ['/error-once/f'] * 2
+
+
+def test_url_that_keeps_failing_falls_through_to_next(mirror, tmp_path):
+    target = str(tmp_path / 'download')
+    repository.__download_from_first([mirror.base + '/stall-always/f', 
mirror.base + '/ok/f'], target)
+
+    assert _read(target) == PAYLOAD
+    assert mirror.requests == ['/stall-always/f'] * 
repository.DOWNLOAD_ATTEMPTS + ['/ok/f']
+
+
+def 
test_untrusted_certificate_falls_through_to_next_without_retry(untrusted_mirror,
 mirror, tmp_path):
+    target = str(tmp_path / 'download')
+    repository.__download_from_first([untrusted_mirror.base + '/ok/f', 
mirror.base + '/ok/f'], target)
+
+    assert _read(target) == PAYLOAD
+    assert untrusted_mirror.connections == 1
+    assert mirror.requests == ['/ok/f']
+
+
+def test_last_error_is_raised_when_no_url_works(mirror, tmp_path):
+    target = str(tmp_path / 'download')
+    with pytest.raises(urllib.error.HTTPError) as e:
+        repository.__download_from_first([mirror.base + '/missing/a', 
mirror.base + '/missing/b'], target)
+
+    assert e.value.code == 404
+    assert mirror.requests == ['/missing/a', '/missing/b']
+
+
+def test_download_version_tries_mirrors_in_order(mirror, tmp_path, 
monkeypatch):
+    tarball = io.BytesIO()
+    with tarfile.open(fileobj=tarball, mode='w:gz') as tar:
+        script = tarfile.TarInfo('apache-cassandra-9.9.9/bin/cassandra')
+        script.size = len(PAYLOAD)
+        tar.addfile(script, io.BytesIO(PAYLOAD))
+    mirror.payload = tarball.getvalue()
+    monkeypatch.setattr(repository, 'MIRRORS', [mirror.base + '/missing', 
mirror.base + '/ok'])
+    monkeypatch.setattr(repository, 'CCM_CONFIG', 
configparser.RawConfigParser())
+    monkeypatch.setattr(repository, 'get_default_path', lambda: str(tmp_path))
+
+    repository.download_version('9.9.9', binary=True)
+
+    assert mirror.requests == 
['/missing/9.9.9/apache-cassandra-9.9.9-bin.tar.gz',
+                               '/ok/9.9.9/apache-cassandra-9.9.9-bin.tar.gz']
+    installed = tmp_path / 'repository' / '9.9.9'
+    assert _read(str(installed / 'bin' / 'cassandra')) == PAYLOAD
+    assert _read(str(installed / '0.version.txt')) == b'9.9.9'


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to