Pass upstream policy errors through PyPI proxy to users
- Add _parse_upstream_error() to extract policy messages from JFrog/Artifactory - Pass through 403 and other 4xx errors with detailed messages - Pin babel and electron-to-chromium to older versions for CI compatibility
This commit is contained in:
@@ -6,6 +6,7 @@ Artifacts are cached on first access through configured upstream sources.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -33,6 +34,55 @@ PROXY_CONNECT_TIMEOUT = 30.0
|
||||
PROXY_READ_TIMEOUT = 60.0
|
||||
|
||||
|
||||
def _parse_upstream_error(response: httpx.Response) -> str:
|
||||
"""Parse upstream error response to extract useful error details.
|
||||
|
||||
Handles JFrog/Artifactory policy errors and other common formats.
|
||||
Returns a user-friendly error message.
|
||||
"""
|
||||
status = response.status_code
|
||||
|
||||
try:
|
||||
body = response.text
|
||||
except Exception:
|
||||
return f"HTTP {status}"
|
||||
|
||||
# Try to parse as JSON (JFrog/Artifactory format)
|
||||
try:
|
||||
data = json.loads(body)
|
||||
|
||||
# JFrog Artifactory format: {"errors": [{"status": 403, "message": "..."}]}
|
||||
if "errors" in data and isinstance(data["errors"], list):
|
||||
messages = []
|
||||
for err in data["errors"]:
|
||||
if isinstance(err, dict) and "message" in err:
|
||||
messages.append(err["message"])
|
||||
if messages:
|
||||
return "; ".join(messages)
|
||||
|
||||
# Alternative format: {"message": "..."}
|
||||
if "message" in data:
|
||||
return data["message"]
|
||||
|
||||
# Alternative format: {"error": "..."}
|
||||
if "error" in data:
|
||||
return data["error"]
|
||||
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Check for policy-related keywords in plain text response
|
||||
policy_keywords = ["policy", "blocked", "forbidden", "curation", "security"]
|
||||
if any(kw in body.lower() for kw in policy_keywords):
|
||||
# Truncate long responses but preserve the message
|
||||
if len(body) > 500:
|
||||
return body[:500] + "..."
|
||||
return body
|
||||
|
||||
# Default: just return status code
|
||||
return f"HTTP {status}"
|
||||
|
||||
|
||||
def _extract_pypi_version(filename: str) -> Optional[str]:
|
||||
"""Extract version from PyPI filename.
|
||||
|
||||
@@ -207,6 +257,7 @@ async def pypi_simple_index(
|
||||
|
||||
# Try each source in priority order
|
||||
last_error = None
|
||||
last_status = None
|
||||
for source in sources:
|
||||
try:
|
||||
headers = {"User-Agent": "Orchard-PyPI-Proxy/1.0"}
|
||||
@@ -252,21 +303,29 @@ async def pypi_simple_index(
|
||||
|
||||
return HTMLResponse(content=content)
|
||||
|
||||
last_error = f"HTTP {response.status_code}"
|
||||
# Parse upstream error for policy/blocking messages
|
||||
last_error = _parse_upstream_error(response)
|
||||
last_status = response.status_code
|
||||
logger.warning(f"PyPI proxy: upstream returned {response.status_code}: {last_error}")
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
last_error = f"Connection failed: {e}"
|
||||
last_status = 502
|
||||
logger.warning(f"PyPI proxy: failed to connect to {source.url}: {e}")
|
||||
except httpx.TimeoutException as e:
|
||||
last_error = f"Timeout: {e}"
|
||||
last_status = 504
|
||||
logger.warning(f"PyPI proxy: timeout connecting to {source.url}: {e}")
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
last_status = 502
|
||||
logger.warning(f"PyPI proxy: error fetching from {source.url}: {e}")
|
||||
|
||||
# Pass through 4xx errors (like 403 policy blocks) so users understand why
|
||||
status_code = last_status if last_status and 400 <= last_status < 500 else 502
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Failed to fetch package index from upstream: {last_error}"
|
||||
status_code=status_code,
|
||||
detail=f"Upstream error: {last_error}"
|
||||
)
|
||||
|
||||
|
||||
@@ -296,6 +355,7 @@ async def pypi_package_versions(
|
||||
|
||||
# Try each source in priority order
|
||||
last_error = None
|
||||
last_status = None
|
||||
for source in sources:
|
||||
try:
|
||||
headers = {"User-Agent": "Orchard-PyPI-Proxy/1.0"}
|
||||
@@ -348,23 +408,32 @@ async def pypi_package_versions(
|
||||
if response.status_code == 404:
|
||||
# Package not found in this source, try next
|
||||
last_error = f"Package not found in {source.name}"
|
||||
last_status = 404
|
||||
continue
|
||||
|
||||
last_error = f"HTTP {response.status_code}"
|
||||
# Parse upstream error for policy/blocking messages
|
||||
last_error = _parse_upstream_error(response)
|
||||
last_status = response.status_code
|
||||
logger.warning(f"PyPI proxy: upstream returned {response.status_code} for {package_name}: {last_error}")
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
last_error = f"Connection failed: {e}"
|
||||
last_status = 502
|
||||
logger.warning(f"PyPI proxy: failed to connect to {source.url}: {e}")
|
||||
except httpx.TimeoutException as e:
|
||||
last_error = f"Timeout: {e}"
|
||||
last_status = 504
|
||||
logger.warning(f"PyPI proxy: timeout connecting to {source.url}: {e}")
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
last_status = 502
|
||||
logger.warning(f"PyPI proxy: error fetching {package_name} from {source.url}: {e}")
|
||||
|
||||
# Pass through 4xx errors (like 403 policy blocks) so users understand why
|
||||
status_code = last_status if last_status and 400 <= last_status < 500 else 404
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Package '{package_name}' not found: {last_error}"
|
||||
status_code=status_code,
|
||||
detail=f"Package '{package_name}' error: {last_error}"
|
||||
)
|
||||
|
||||
|
||||
@@ -484,9 +553,12 @@ async def pypi_download_file(
|
||||
redirect_count += 1
|
||||
|
||||
if response.status_code != 200:
|
||||
# Parse upstream error for policy/blocking messages
|
||||
error_detail = _parse_upstream_error(response)
|
||||
logger.warning(f"PyPI proxy: upstream returned {response.status_code} for {filename}: {error_detail}")
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Upstream returned {response.status_code}"
|
||||
detail=f"Upstream error: {error_detail}"
|
||||
)
|
||||
|
||||
content_type = response.headers.get('content-type', 'application/octet-stream')
|
||||
|
||||
Reference in New Issue
Block a user