refactor(image): deduplicate retry logic into registry adapter base class

- Move _request_with_retry, _next_page_url, _MAX_RETRIES, _BACKOFF_BASE
  from OciRegistryAdapter and DockerHubAdapter into RegistryAdapter base
- Add context_label parameter to _request_with_retry for contextual
  error/warning messages (defaults to self.registry_url)
- Route _hub_login and _get_registry_token through retry logic
- Change 404 exception in _check_hub_response from ImageRegistryAuthError
  to ImageRegistryCatalogError (missing namespace is not an auth failure)
- Remove redundant time, requests imports from leaf adapters
This commit is contained in:
Andoni A.
2026-02-17 09:39:15 +01:00
parent 402b33516b
commit 310a32e391
3 changed files with 71 additions and 103 deletions
@@ -2,8 +2,20 @@
from __future__ import annotations
import re
import time
from abc import ABC, abstractmethod
import requests
from prowler.lib.logger import logger
from prowler.providers.image.exceptions.exceptions import (
ImageRegistryNetworkError,
)
_MAX_RETRIES = 3
_BACKOFF_BASE = 1
class RegistryAdapter(ABC):
"""Abstract base class for registry adapters."""
@@ -26,3 +38,50 @@ class RegistryAdapter(ABC):
def list_tags(self, repository: str) -> list[str]:
"""Enumerate all tags for a repository."""
...
def _request_with_retry(self, method, url, **kwargs):
context_label = kwargs.pop("context_label", None) or self.registry_url
kwargs.setdefault("timeout", 30)
kwargs.setdefault("verify", self.verify_ssl)
last_exception = None
for attempt in range(1, _MAX_RETRIES + 1):
try:
resp = requests.request(method, url, **kwargs)
if resp.status_code == 429:
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
logger.warning(
f"Rate limited by {context_label}, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
)
time.sleep(wait)
continue
return resp
except requests.exceptions.ConnectionError as exc:
last_exception = exc
if attempt < _MAX_RETRIES:
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
logger.warning(
f"Connection error to {context_label}, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
)
time.sleep(wait)
continue
except requests.exceptions.Timeout as exc:
raise ImageRegistryNetworkError(
file=__file__,
message=f"Connection timed out to {context_label}.",
original_exception=exc,
)
raise ImageRegistryNetworkError(
file=__file__,
message=f"Failed to connect to {context_label} after {_MAX_RETRIES} attempts.",
original_exception=last_exception,
)
@staticmethod
def _next_page_url(resp):
link_header = resp.headers.get("Link", "")
if not link_header:
return None
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
if match:
return match.group(1)
return None
@@ -3,19 +3,15 @@
from __future__ import annotations
import re
import time
import requests
from prowler.lib.logger import logger
from prowler.providers.image.exceptions.exceptions import (
ImageRegistryAuthError,
ImageRegistryCatalogError,
ImageRegistryNetworkError,
)
from prowler.providers.image.lib.registry.base import RegistryAdapter
_MAX_RETRIES = 3
_BACKOFF_BASE = 1
_HUB_API = "https://hub.docker.com"
_REGISTRY_HOST = "https://registry-1.docker.io"
_AUTH_URL = "https://auth.docker.io/token"
@@ -106,11 +102,11 @@ class DockerHubAdapter(RegistryAdapter):
return
if not self.username or not self.password:
return
resp = requests.post(
resp = self._request_with_retry(
"POST",
f"{_HUB_API}/v2/users/login",
json={"username": self.username, "password": self.password},
verify=self.verify_ssl,
timeout=30,
context_label="Docker Hub",
)
if resp.status_code != 200:
raise ImageRegistryAuthError(
@@ -129,8 +125,8 @@ class DockerHubAdapter(RegistryAdapter):
auth = None
if self.username and self.password:
auth = (self.username, self.password)
resp = requests.get(
_AUTH_URL, params=params, auth=auth, verify=self.verify_ssl, timeout=30
resp = self._request_with_retry(
"GET", _AUTH_URL, params=params, auth=auth, context_label="Docker Hub",
)
if resp.status_code != 200:
raise ImageRegistryAuthError(
@@ -142,54 +138,20 @@ class DockerHubAdapter(RegistryAdapter):
return token
def _hub_request(self, method, url, **kwargs):
kwargs.setdefault("timeout", 30)
kwargs.setdefault("verify", self.verify_ssl)
headers = kwargs.pop("headers", {})
if self._hub_jwt:
headers["Authorization"] = f"Bearer {self._hub_jwt}"
kwargs["headers"] = headers
return self._request_with_retry(method, url, **kwargs)
return self._request_with_retry(
method, url, context_label="Docker Hub", **kwargs
)
def _registry_request(self, method, url, token, **kwargs):
kwargs.setdefault("timeout", 30)
kwargs.setdefault("verify", self.verify_ssl)
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"Bearer {token}"
kwargs["headers"] = headers
return self._request_with_retry(method, url, **kwargs)
def _request_with_retry(self, method, url, **kwargs):
last_exception = None
for attempt in range(1, _MAX_RETRIES + 1):
try:
resp = requests.request(method, url, **kwargs)
if resp.status_code == 429:
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
logger.warning(
f"Rate limited by Docker Hub, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
)
time.sleep(wait)
continue
return resp
except requests.exceptions.ConnectionError as exc:
last_exception = exc
if attempt < _MAX_RETRIES:
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
logger.warning(
f"Connection error to Docker Hub, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
)
time.sleep(wait)
continue
except requests.exceptions.Timeout as exc:
raise ImageRegistryNetworkError(
file=__file__,
message="Connection timed out to Docker Hub.",
original_exception=exc,
)
raise ImageRegistryNetworkError(
file=__file__,
message=f"Failed to connect to Docker Hub after {_MAX_RETRIES} attempts.",
original_exception=last_exception,
return self._request_with_retry(
method, url, context_label="Docker Hub", **kwargs
)
def _check_hub_response(self, resp, context):
@@ -201,7 +163,7 @@ class DockerHubAdapter(RegistryAdapter):
message=f"Authentication failed for {context} on Docker Hub (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD environment variables.",
)
if resp.status_code == 404:
raise ImageRegistryAuthError(
raise ImageRegistryCatalogError(
file=__file__,
message=f"Namespace '{self.namespace}' not found on Docker Hub. Check the namespace in --registry docker.io/{{namespace}}.",
)
@@ -4,11 +4,7 @@ from __future__ import annotations
import base64
import re
import time
import requests
from prowler.lib.logger import logger
from prowler.providers.image.exceptions.exceptions import (
ImageRegistryAuthError,
ImageRegistryCatalogError,
@@ -16,9 +12,6 @@ from prowler.providers.image.exceptions.exceptions import (
)
from prowler.providers.image.lib.registry.base import RegistryAdapter
_MAX_RETRIES = 3
_BACKOFF_BASE = 1
class OciRegistryAdapter(RegistryAdapter):
"""Adapter for registries implementing OCI Distribution Spec."""
@@ -162,42 +155,6 @@ class OciRegistryAdapter(RegistryAdapter):
kwargs["headers"] = headers
return self._request_with_retry(method, url, **kwargs)
def _request_with_retry(self, method, url, **kwargs):
kwargs.setdefault("timeout", 30)
kwargs.setdefault("verify", self.verify_ssl)
last_exception = None
for attempt in range(1, _MAX_RETRIES + 1):
try:
resp = requests.request(method, url, **kwargs)
if resp.status_code == 429:
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
logger.warning(
f"Rate limited by registry, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
)
time.sleep(wait)
continue
return resp
except requests.exceptions.ConnectionError as exc:
last_exception = exc
if attempt < _MAX_RETRIES:
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
logger.warning(
f"Connection error to registry, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
)
time.sleep(wait)
continue
except requests.exceptions.Timeout as exc:
raise ImageRegistryNetworkError(
file=__file__,
message=f"Connection timed out to {self.registry_url}.",
original_exception=exc,
)
raise ImageRegistryNetworkError(
file=__file__,
message=f"Failed to connect to {self.registry_url} after {_MAX_RETRIES} attempts.",
original_exception=last_exception,
)
def _check_response(self, resp, context):
if resp.status_code == 200:
return
@@ -210,13 +167,3 @@ class OciRegistryAdapter(RegistryAdapter):
file=__file__,
message=f"Unexpected error during {context} on {self.registry_url} (HTTP {resp.status_code}): {resp.text[:200]}",
)
@staticmethod
def _next_page_url(resp):
link_header = resp.headers.get("Link", "")
if not link_header:
return None
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
if match:
return match.group(1)
return None