Skip to content

Commit 2854331

Browse files
committed
Add RFC9457 support problem details handling
1 parent 758a172 commit 2854331

File tree

4 files changed

+287
-1
lines changed

4 files changed

+287
-1
lines changed

src/pip/_internal/exceptions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
from pip._internal.metadata import BaseDistribution
3333
from pip._internal.network.download import _FileDownload
34+
from pip._internal.network.rfc9457 import ProblemDetails
3435
from pip._internal.req.req_install import InstallRequirement
3536

3637
logger = logging.getLogger(__name__)
@@ -335,6 +336,30 @@ def __str__(self) -> str:
335336
return str(self.error_msg)
336337

337338

339+
class HTTPProblemDetailsError(NetworkConnectionError):
340+
"""HTTP error with RFC 9457 Problem Details."""
341+
342+
def __init__(
343+
self,
344+
problem_details: ProblemDetails,
345+
response: Response,
346+
) -> None:
347+
"""
348+
Initialize HTTPProblemDetailsError with problem details.
349+
350+
Args:
351+
problem_details: Parsed RFC 9457 problem details
352+
response: The HTTP response object
353+
"""
354+
self.problem_details = problem_details
355+
error_msg = str(problem_details)
356+
357+
super().__init__(
358+
error_msg=error_msg,
359+
response=response,
360+
)
361+
362+
338363
class InvalidWheelFilename(InstallationError):
339364
"""Invalid wheel filename."""
340365

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""RFC 9457 - Problem Details for HTTP APIs
2+
3+
This module provides support for RFC 9457 (Problem Details for HTTP APIs),
4+
a standardized format for describing errors in HTTP APIs.
5+
6+
Reference: https://www.rfc-editor.org/rfc/rfc9457
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import logging
13+
from dataclasses import dataclass
14+
from typing import Any
15+
16+
from pip._vendor.requests.models import Response
17+
18+
logger = logging.getLogger(__name__)
19+
20+
RFC9457_CONTENT_TYPE = "application/problem+json"
21+
22+
@dataclass
23+
class ProblemDetails:
24+
"""Represents an RFC 9457 Problem Details object.
25+
26+
This class encapsulates the core fields defined in RFC 9457:
27+
- status: The HTTP status code
28+
- title: A short, human-readable summary of the problem type
29+
- detail: A human-readable explanation specific to this occurrence
30+
"""
31+
32+
status: int | None = None
33+
title: str | None = None
34+
detail: str | None = None
35+
36+
@classmethod
37+
def from_dict(cls, data: dict[str, Any]) -> ProblemDetails:
38+
return cls(
39+
status=data.get("status"),
40+
title=data.get("title"),
41+
detail=data.get("detail"),
42+
)
43+
44+
@classmethod
45+
def from_json(cls, json_str: str) -> ProblemDetails:
46+
data = json.loads(json_str)
47+
if not isinstance(data, dict):
48+
raise ValueError("Problem details JSON must be an object")
49+
return cls.from_dict(data)
50+
51+
def __str__(self) -> str:
52+
parts = []
53+
54+
if self.title:
55+
parts.append(f"{self.title}")
56+
if self.status:
57+
parts.append(f"(Status: {self.status})")
58+
if self.detail:
59+
parts.append(f"\n{self.detail}")
60+
61+
return " ".join(parts) if parts else "Unknown problem"
62+
63+
64+
def is_problem_details_response(response: Response) -> bool:
65+
content_type = response.headers.get("Content-Type", "")
66+
return content_type.startswith(RFC9457_CONTENT_TYPE)
67+
68+
69+
def parse_problem_details(response: Response) -> ProblemDetails | None:
70+
if not is_problem_details_response(response):
71+
return None
72+
73+
try:
74+
body = response.content
75+
if not body:
76+
logger.warning("Problem details response has empty body")
77+
return None
78+
79+
problem = ProblemDetails.from_json(body.decode("utf-8"))
80+
81+
if problem.status is None:
82+
problem.status = response.status_code
83+
84+
logger.debug("Parsed problem details: status=%s, title=%s", problem.status, problem.title)
85+
return problem
86+
87+
except (json.JSONDecodeError, ValueError):
88+
return None

src/pip/_internal/network/utils.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from pip._vendor.requests.models import Response
44

5-
from pip._internal.exceptions import NetworkConnectionError
5+
from pip._internal.exceptions import HTTPProblemDetailsError, NetworkConnectionError
6+
from pip._internal.network.rfc9457 import parse_problem_details
67

78
# The following comments and HTTP headers were originally added by
89
# Donald Stufft in git commit 22c562429a61bb77172039e480873fb239dd8c03.
@@ -29,6 +30,14 @@
2930

3031

3132
def raise_for_status(resp: Response) -> None:
33+
problem_details = parse_problem_details(resp)
34+
if problem_details:
35+
raise HTTPProblemDetailsError(
36+
problem_details=problem_details,
37+
response=resp,
38+
)
39+
40+
# Fallback to standard error handling for non-RFC 9457 responses
3241
http_error_msg = ""
3342
if isinstance(resp.reason, bytes):
3443
# We attempt to decode utf-8 first because some servers

tests/unit/test_network_rfc9457.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Tests for RFC 9457 (Problem Details for HTTP APIs) support."""
2+
3+
import json
4+
5+
import pytest
6+
7+
from pip._internal.exceptions import HTTPProblemDetailsError, NetworkConnectionError
8+
from pip._internal.network.rfc9457 import (
9+
ProblemDetails,
10+
is_problem_details_response,
11+
parse_problem_details,
12+
)
13+
from pip._internal.network.utils import raise_for_status
14+
from tests.lib.requests_mocks import MockResponse
15+
16+
17+
class TestProblemDetails:
18+
def test_from_dict(self) -> None:
19+
data = {
20+
"status": 404,
21+
"title": "Not Found",
22+
"detail": "Resource not found",
23+
}
24+
25+
problem = ProblemDetails.from_dict(data)
26+
assert problem.status == 404
27+
assert problem.title == "Not Found"
28+
assert problem.detail == "Resource not found"
29+
30+
def test_from_json(self) -> None:
31+
json_str = json.dumps({
32+
"status": 404,
33+
"title": "Not Found",
34+
"detail": "Resource not found",
35+
})
36+
37+
problem = ProblemDetails.from_json(json_str)
38+
assert problem.status == 404
39+
assert problem.title == "Not Found"
40+
41+
def test_string_representation(self) -> None:
42+
"""Test string representation of ProblemDetails."""
43+
problem = ProblemDetails(
44+
status=403,
45+
title="Access Denied",
46+
detail="Your API token does not have permission",
47+
)
48+
49+
str_repr = str(problem)
50+
assert "Access Denied" in str_repr
51+
assert "403" in str_repr
52+
assert "API token" in str_repr
53+
54+
class TestIsProblemDetailsResponse:
55+
def test_detects_problem_json_content_type(self) -> None:
56+
resp = MockResponse(b"")
57+
resp.headers = {"Content-Type": "application/problem+json"}
58+
59+
assert is_problem_details_response(resp) is True
60+
61+
def test_detects_problem_json_with_charset(self) -> None:
62+
resp = MockResponse(b"")
63+
resp.headers = {"Content-Type": "application/problem+json; charset=utf-8"}
64+
65+
assert is_problem_details_response(resp) is True
66+
67+
def test_does_not_detect_regular_json(self) -> None:
68+
resp = MockResponse(b"")
69+
resp.headers = {"Content-Type": "application/json"}
70+
71+
assert is_problem_details_response(resp) is False
72+
73+
def test_does_not_detect_without_content_type(self) -> None:
74+
resp = MockResponse(b"")
75+
resp.headers = {}
76+
77+
assert is_problem_details_response(resp) is False
78+
79+
class TestParseProblemDetails:
80+
def test_parses_valid_problem_details(self) -> None:
81+
problem_data = {
82+
"status": 404,
83+
"title": "Not Found",
84+
"detail": "The package 'test-package' was not found",
85+
}
86+
resp = MockResponse(json.dumps(problem_data).encode())
87+
resp.headers = {"Content-Type": "application/problem+json"}
88+
resp.status_code = 404
89+
90+
problem = parse_problem_details(resp)
91+
assert problem is not None
92+
assert problem.status == 404
93+
assert problem.title == "Not Found"
94+
assert "test-package" in problem.detail
95+
96+
def test_returns_none_for_non_problem_details(self) -> None:
97+
resp = MockResponse(b"<html>Error</html>")
98+
resp.headers = {"Content-Type": "text/html"}
99+
100+
problem = parse_problem_details(resp)
101+
assert problem is None
102+
103+
def test_handles_malformed_json(self) -> None:
104+
resp = MockResponse(b"not valid json")
105+
resp.headers = {"Content-Type": "application/problem+json"}
106+
107+
problem = parse_problem_details(resp)
108+
assert problem is None
109+
110+
@pytest.mark.parametrize(
111+
"status_code, title, detail",
112+
[
113+
(404, "Package Not Found", "The requested package does not exist"),
114+
(500, "Internal Server Error", "An unexpected error occurred"),
115+
(403, "Forbidden", "Access denied to this resource"),
116+
],
117+
)
118+
119+
class TestRaiseForStatusWithProblemDetails:
120+
def test_raises_http_problem_details_error(
121+
self, status_code: int, title: str, detail: str
122+
) -> None:
123+
problem_data = {
124+
"status": status_code,
125+
"title": title,
126+
"detail": detail,
127+
}
128+
resp = MockResponse(json.dumps(problem_data).encode())
129+
resp.status_code = status_code
130+
resp.headers = {"Content-Type": "application/problem+json"}
131+
resp.url = "https://pypi.org/simple/some-package/"
132+
133+
with pytest.raises(HTTPProblemDetailsError) as excinfo:
134+
raise_for_status(resp)
135+
136+
assert excinfo.value.problem_details.status == status_code
137+
assert excinfo.value.problem_details.title == title
138+
assert excinfo.value.response == resp
139+
140+
141+
@pytest.mark.parametrize(
142+
"status_code, error_type",
143+
[
144+
(404, "Client Error"),
145+
(500, "Server Error"),
146+
(403, "Client Error"),
147+
],
148+
)
149+
150+
class TestRaiseForStatusBackwardCompatibility:
151+
def test_raises_network_connection_error(
152+
self, status_code: int, error_type: str
153+
) -> None:
154+
resp = MockResponse(b"<html>Error</html>")
155+
resp.status_code = status_code
156+
resp.headers = {"Content-Type": "text/html"}
157+
resp.url = "https://pypi.org/simple/nonexistent-package/"
158+
resp.reason = "Error"
159+
160+
with pytest.raises(NetworkConnectionError) as excinfo:
161+
raise_for_status(resp)
162+
163+
assert f"{status_code} {error_type}" in str(excinfo.value)
164+
assert excinfo.value.response == resp

0 commit comments

Comments
 (0)