Set X-Error-Message response header on RubyGems errors

🔔 Context

The GitLab RubyGems package registry returns error responses as Content-Type: application/json with a body like {"message":"403 Forbidden - Package protected."}. The gem client never reads the response body during gem install / gem fetch — it only reads the HTTP status code and reason phrase, so users see generic output (Bad response Forbidden 403 (...)) with no idea why an operation failed.

Gem::RemoteFetcher#fetch_http reads an X-Error-Message response header and, when present, uses its value in place of the HTTP reason phrase.

Related to #595492 (parent: #595485).

🤔 What does this MR do and why?

  • Add the X-Error-Message response header to all 4xx/5xx error responses from the RubyGems API endpoints, gated behind the rubygems_error_message_header feature flag (disabled by default).
  • The header value is the error detail with the "NNN StatusPhrase - " prefix stripped (e.g. 403 Forbidden - Package protected.Package protected.). When the message is a bare status phrase (e.g. 403 Forbidden) the header is not set — the client already shows the reason phrase. Custom messages without a status prefix (e.g. the dependency resolver's <gem> not found) are passed through verbatim.
  • The response body is unchanged — existing API consumers that parse the JSON are unaffected.

The implementation introduces API::Helpers::Packages::Rubygems::ErrorMessageHeader, which overrides render_structured_api_error! — the single chokepoint all Grape error helpers (forbidden!, bad_request!, not_found!, …) flow through. When the flag is disabled it calls super immediately (zero behavioral change).

This also de-duplicates the "NNN StatusPhrase - detail" parsing that previously lived inline only in the Maven helper: it is extracted into a shared API::Helpers::Packages::ErrorMessage module (error_message_detail, error_message_single_line) and reused by the Maven and NuGet error helpers. Their behavior is unchanged — guaranteed by their existing specs staying green.

⚠️ RubyGems client compatibility

The mechanism is version-dependent and worth calling out explicitly:

Operation rubygems < 4.0 rubygems 4.x
gem install / gem fetch reads X-Error-Message header handling removed — ignored (harmless); body discarded on error, so no server-side channel exists
gem push / gem yank reads response body reads response body (unchanged)

I verified against the real Gem::RemoteFetcher#fetch_http that rubygems 4.0.12 no longer reads X-Error-Message (and there is no replacement header). So the benefit of this change is scoped to gem install/gem fetch clients on rubygems < 4.0; on 4.x the header is simply ignored. The change is harmless and standards-based on all versions.

🚩 Feature flag

Name: rubygems_error_message_header (gitlab_com_derisk, default disabled). Rollout: #601846

🧪 How to set up and validate locally

# Rails console
Feature.enable(:rubygems_error_message_header)
# 404 with a detail (dependency resolver) -> header set
curl -sI -H "Private-Token: $TOKEN" \
  "http://gdk.test:3000/api/v4/projects/$PID/packages/rubygems/api/v1/dependencies?gems=nonexistent" \
  | grep -i x-error-message      # => X-Error-Message: nonexistent not found

# bare status phrase (gem file not found) -> no header
curl -sI -H "Private-Token: $TOKEN" \
  "http://gdk.test:3000/api/v4/projects/$PID/packages/rubygems/gems/nonexistent-1.0.gem" \
  | grep -i x-error-message      # => (no output)

With the flag disabled, the header is never present.

📚 References

MR acceptance checklist

  • Tests added (unit specs for the shared helper and the RubyGems override; request specs for header presence/absence + flag-off; Maven/NuGet regression specs unchanged)
  • Feature flag created (rubygems_error_message_header)
Edited by Radamanthus Batnag

Merge request reports

Loading