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:
Mondo Diaz
2026-02-03 08:09:08 -06:00
parent eb11efd001
commit b2a8c7cfcc
3 changed files with 147 additions and 77 deletions

View File

@@ -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')