Skip to content

Surface X-Error-Message response header in HTTP error details#14021

Open
gineshkumar wants to merge 3 commits into
pypa:mainfrom
gineshkumar:add-x-error-message-support
Open

Surface X-Error-Message response header in HTTP error details#14021
gineshkumar wants to merge 3 commits into
pypa:mainfrom
gineshkumar:add-x-error-message-support

Conversation

@gineshkumar

@gineshkumar gineshkumar commented May 31, 2026

Copy link
Copy Markdown

Surface X-Error-Message response header in HTTP error details

Closes #14020

Problem

pip._internal.network.utils.raise_for_status builds its error message from
resp.reason (the HTTP reason phrase). The reason phrase is an unreliable
channel for actionable error detail:

  • HTTP/2 has no reason phrase at all (RFC 7540 §8.1.2.4). Client
    libraries synthesize a default ("Forbidden", "Not Found") from the
    status code when none is present on the wire. In modern deployments
    the client almost always speaks HTTP/2 to a CDN or edge proxy, so any
    reason phrase set by the origin is lost at protocol translation.
  • HTTP/1.1 allows any value for the reason phrase, including empty
    (RFC 7230 §3.1.2).

The result is that users see 403 Client Error: Forbidden for url: ...
with no explanation, even when the server has a useful explanation to
give (e.g. "blocked by policy", "package not in this index", "MFA
required").

Change

When a server returns an X-Error-Message response header on a 4xx or
5xx response, raise_for_status now uses that header's value as the
error detail in place of resp.reason. Servers that don't send the
header see no change in behaviour.

This also aligns the two raise_for_status call sites in
network/lazy_wheel.py — one was using pip's helper, the other was
using the requests built-in. Both now raise the same exception type
and both honour X-Error-Message.

Alternative considered: application/problem+json (RFC 7807)

RFC 7807 defines a standardised JSON body format for HTTP error details
(application/problem+json with type, title, status, detail,
instance fields).

We chose X-Error-Message over RFC 7807 for two reasons:

  1. Simpler. Reading one header is a smaller, more contained change
    than parsing a JSON body with a new content type — and a single
    human-readable string is all raise_for_status needs today.
  2. Existing precedent. Hugging Face Hub and RubyGems already use
    X-Error-Message for this; NuGet uses X-NuGet-Warning (same
    pattern). See raise_for_status relies on HTTP reason phrase, which is unreliable in practice #14020 for details. Matching the convention gives a
    server one header that lights up actionable errors across multiple
    clients.

If a future change wants richer structured error details, RFC 7807
remains a clean additive option — this header does not preclude it.

What is not in this PR

  • index/collector.py simple-index 4xx responses are still logged at
    logger.debug level via _handle_get_simple_fail. A useful
    X-Error-Message on a 403 from a simple index would still be silent
    at default verbosity. That's a pre-existing behaviour, separate from
    this header change.
  • network/auth.py's 401 retry path does not read X-Error-Message.
    Out of scope here; worth a follow-up.

News entry

news/14020.feature.rst (towncrier feature fragment).

@sepehr-rs

Copy link
Copy Markdown
Member

Hi @gineshkumar, thank you for your contribution!
Please note that pip is a volunteer project, so reviews may take a little time. In the meantime, let me know if you need any help with the failing test.

When a server returns an X-Error-Message response header alongside a
4xx or 5xx error, raise_for_status now uses that value as the error
detail instead of resp.reason.  Servers that do not send the header
are unaffected.

Closes pypa#14020.
_download was calling response.raise_for_status() (the requests
built-in) while the rest of lazy_wheel.py already used pip's helper.
Switch to raise_for_status(response) so both call sites raise
NetworkConnectionError and both honour the X-Error-Message header.
- Parametrize new tests over 4xx and 5xx status codes (matching
  existing pip test style)
- Assert header value appears in error and reason phrase does not
- Cover the lazy_wheel._download path via LazyZipOverHTTP.__new__
  and patch.object(_stream_response)
@gineshkumar gineshkumar force-pushed the add-x-error-message-support branch from 3fdb425 to 2284350 Compare May 31, 2026 07:18
@gineshkumar

Copy link
Copy Markdown
Author

In the meantime, let me know if you need any help with the failing test.

Tests look good after rebasing, thanks @sepehr-rs.

@pradyunsg

Copy link
Copy Markdown
Member

https://discuss.python.org/t/pre-pep-discussion-rfc-9457-error-responses-for-package-registries/105453

There is active discussions on using RFC 9457 for index error messages.

@ichard26 ichard26 added the state: blocked Can not be done until something else is done label Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided state: blocked Can not be done until something else is done

Projects

None yet

Development

Successfully merging this pull request may close these issues.

raise_for_status relies on HTTP reason phrase, which is unreliable in practice

4 participants