Conan registry: return Artifactory-compatible JSON error responses
## :fire: Problem
The GitLab Conan package registry cannot communicate meaningful error messages to Conan clients (`conan` v1 and v2).
Currently, all error responses use `Content-Type: application/json` with a body like `{"message": "403 Forbidden - Package protected."}`. The Conan client's `response_to_str()` function attempts to parse this as [Artifactory-compatible JSON](https://jfrog.com/help/r/jfrog-rest-apis/errors), but since GitLab's format doesn't match, the **parsing silently fails** and the raw JSON string is displayed to users:
```
ERROR: 403 Forbidden. Remote: {"message": "403 Forbidden - Package protected."}
```
When the Artifactory JSON format is used, Conan extracts and formats the message cleanly:
```
ERROR: 403 Forbidden. Remote: 403: Package protected.
```
This affects all upload, authentication, and some download error scenarios. Users see unhelpful raw JSON instead of clean, parsed error messages.
## :bulb: Solution
Return [Artifactory-compatible JSON error responses](https://jfrog.com/help/r/jfrog-rest-apis/errors) from all Conan package registry endpoints.
When enabled, all 4xx/5xx responses from Conan endpoints will return:
- `Content-Type: application/json` (already the default)
- A JSON body with the `errors` array format
### :mag: Example
```http
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"errors": [
{
"status": 403,
"message": "Package protected."
}
]
}
```
Conan v1/v2 users will then see:
```
ERROR: 403 Forbidden. Remote: 403: Package protected.
```
### :gear: Design details
- Use the Artifactory JSON format: `{"errors":[{"status":N,"message":"..."}]}` — this is the de facto standard for Conan server error responses.
- Feature-flagged as `conan_artifactory_error_responses` (default disabled for gradual rollout).
- The `message` field MUST NOT leak internal infrastructure details (hostnames, stack traces, etc.).
- Extract a clean `message` from the legacy format by stripping the `"NNN StatusPhrase - "` prefix (e.g., `"403 Forbidden - Package protected."` becomes `"Package protected."`). When no detail is present (e.g., bare `forbidden!`), use the HTTP status phrase as the message.
### :shield: Backward compatibility
This change is fully backward-compatible:
- **Conan v1 and v2 clients already call `response_to_str()`** on error responses. This function checks for `Content-Type: application/json` and attempts to parse `{"errors":[...]}`. Today, parsing fails silently (caught by a bare `except`) and falls back to showing the raw body. With the new format, parsing succeeds and users see clean messages instead.
- **Content-Type remains `application/json`** — no change to the response content type.
- **Status codes remain unchanged** — clients that only check status codes are unaffected.
- **Conan v1.37+ and all v2** handle `Content-Type: application/json; charset=utf-8` correctly after [conan-io/conan#8912](https://github.com/conan-io/conan/pull/8912). Older v1 versions (<1.37) fail on charset parameters, but GitLab's Grape framework returns `application/json` without charset by default, so this is not an issue.
## :link: Client compatibility matrix
### :satellite: How Conan clients handle HTTP errors
There are two mechanisms a server can use to communicate error information to Conan clients:
1. **HTTP status code** — always available, all clients use it
2. **Response body** — parsed via `response_to_str()` which extracts Artifactory JSON or falls back to raw text
### :conan: Conan error response parsing (`response_to_str`)
The [`response_to_str()`](https://github.com/conan-io/conan/blob/develop2/conans/client/rest/__init__.py) function is the single point where Conan clients extract error details from server responses:
```python
def response_to_str(response):
content_type = response.headers.get("content-type")
if content_type == "application/json":
# Artifactory format: {"errors":[{"status":N,"message":"..."}]}
data = json.loads(content)["errors"][0]
content = "{}: {}".format(data["status"], data["message"])
elif "text/html" in content_type:
content = "{}: {}".format(response.status_code, response.reason)
return content # raw text for anything else
```
**Key behavior:**
- Checks `Content-Type` for exact `"application/json"` match
- Parses the **first** error in the `errors` array
- Formats as `"status: message"` (e.g., `"403: Package protected."`)
- Falls back to raw body content if JSON parsing fails (bare `except` catches all)
- HTML responses show only `"status_code: reason_phrase"`
### :package: Error status code to exception mapping
Conan maps HTTP status codes to specific exception types via [`get_exception_from_error()`](https://github.com/conan-io/conan/blob/develop2/conans/errors.py):
| HTTP Status | Conan Exception | User-visible prefix |
|---|---|---|
| 400 | `RequestErrorException` | `ERROR: 400 Bad Request` |
| 401 | `AuthenticationException` | `ERROR: 401 Unauthorized` |
| 403 | `ForbiddenException` | `ERROR: 403 Forbidden` |
| 404 | `NotFoundException` | `ERROR: 404 Not Found` |
| 500 | `InternalErrorException` | `ERROR: 500 Internal Server Error` |
### :arrow_down: Download operations (FileDownloader)
The [FileDownloader](https://github.com/conan-io/conan/blob/develop2/conans/client/downloaders/file_downloader.py) has **hardcoded error handling** for some status codes:
| Status | Conan v1 | Conan v2 (2.3.0+) | Body passthrough? |
|---|---|---|---|
| 404 | `NotFoundException("Not found: %s" % url)` | Same | **No** — hardcoded URL-only message |
| 5xx | `ConanException("Error %d downloading file %s")` | Same | **No** — hardcoded status + URL |
| 403 | `ForbiddenException(response_to_str(response))` | Same | **Yes** — body parsed |
| 401 | `AuthenticationException()` (no body) | `AuthenticationException(response_to_str(response))` | **v2 only** ([conan-io/conan#15983](https://github.com/conan-io/conan/pull/15983)) |
### :arrow_up: Upload and auth operations (REST client)
For upload, search, and authentication operations, **all** non-200 responses pass through `response_to_str()`:
```python
if response.status_code != 200:
raise get_exception_from_error(response.status_code)(response_to_str(response))
```
This means all 4xx/5xx error bodies are parsed and displayed to users for upload and auth flows.
### :bar_chart: Summary
| Operation | Status codes with body passthrough | Notes |
|---|---|---|
| **Upload** (recipe/package files) | All 4xx/5xx | `response_to_str()` always called |
| **Authentication** (`/users/authenticate`) | All 4xx/5xx | `response_to_str()` always called |
| **Search** | All 4xx/5xx | `response_to_str()` always called |
| **Download** 403 | 403 | Body parsed via `response_to_str()` |
| **Download** 401 | 401 (v2 only) | v1 shows generic message; v2.3.0+ parses body |
| **Download** 404 | — | Hardcoded: shows URL only |
| **Download** 5xx | — | Hardcoded: shows status + URL only |
### :twisted_rightwards_arrows: Conan v1 vs v2
| Feature | Conan v1 | Conan v2 |
|---|---|---|
| `response_to_str()` | Present (same logic) | Present (same logic) |
| Artifactory JSON parsing | Yes | Yes |
| Content-Type charset handling | Fixed in v1.37+ ([conan-io/conan#8912](https://github.com/conan-io/conan/pull/8912)) | Yes |
| 401 body in downloads | No (generic message) | Yes (v2.3.0+, [conan-io/conan#15983](https://github.com/conan-io/conan/pull/15983)) |
| `get_json()` Content-Type check | `== "application/json"` or `"application/json; charset=utf-8"` | Same |
References:
- [response_to_str (v2)](https://github.com/conan-io/conan/blob/develop2/conans/client/rest/__init__.py)
- [FileDownloader (v2)](https://github.com/conan-io/conan/blob/develop2/conans/client/downloaders/file_downloader.py)
- [rest_client_common.py (v2)](https://github.com/conan-io/conan/blob/develop2/conans/client/rest/rest_client_common.py)
- [errors.py (v2)](https://github.com/conan-io/conan/blob/develop2/conans/errors.py)
- [conan-io/conan#8884](https://github.com/conan-io/conan/issues/8884) — Content-Type charset handling issue
- [conan-io/conan#15983](https://github.com/conan-io/conan/pull/15983) — 401 error body passthrough in FileDownloader
- [conan-io/conan#6193](https://github.com/conan-io/conan/issues/6193) — 403 Artifactory permission error handling
## :wrench: Implementation approach
Create a helper module `API::Helpers::Packages::Conan::ArtifactoryErrors` that overrides `render_structured_api_error!` — the single method all error helpers flow through:
```
forbidden!("reason") / not_found!("Package") / bad_request!("reason") / ...
→ render_api_error_with_reason! or render_api_error!
→ render_structured_api_error!({ 'message' => "4xx ... - reason" }, status)
→ error!(hash, status, header) # Grape's method
```
The override:
1. Checks the `conan_artifactory_error_responses` feature flag
2. Only transforms 4xx/5xx responses (not 2xx like `no_content!` which also flows through this path)
3. Extracts a clean `message` from the legacy message string by stripping the `"NNN StatusPhrase - "` prefix
4. Constructs the Artifactory JSON body: `{"errors":[{"status":N,"message":"..."}]}`
5. Calls Grape's `error!` with the transformed body
6. When the flag is disabled, calls `super` — zero behavioral change
Include this module in the Conan API classes — no impact on other API endpoints.
**Note:** The `users/authenticate` endpoint uses `format :txt` with `content_type :txt, 'text/plain'`. The `unauthorized!` call on this endpoint will still flow through `render_structured_api_error!`, but the Grape `format` directive controls the final Content-Type. This needs special attention during implementation to ensure the Artifactory JSON format is returned with `application/json` Content-Type even on the `users` namespace, or alternatively this endpoint can be excluded since the 401 on this endpoint is a simple "no token" case.
## :file_folder: Impacted files
### :new: New files
| File | Purpose |
|---|---|
| `config/feature_flags/development/conan_artifactory_error_responses.yml` | Feature flag definition |
| `lib/api/helpers/packages/conan/artifactory_errors.rb` | Helper module overriding `render_structured_api_error!` |
| `spec/lib/api/helpers/packages/conan/artifactory_errors_spec.rb` | Unit tests for the helper |
### :pencil2: Modified files
| File | Change |
|---|---|
| `lib/api/conan/v1/project_packages.rb` | Include the helper module |
| `lib/api/conan/v1/instance_packages.rb` | Include the helper module |
| `lib/api/conan/v2/project_packages.rb` | Include the helper module |
| `spec/requests/api/conan/v1/project_packages_spec.rb` | Add Artifactory error format shared examples + feature flag contexts |
| `spec/requests/api/conan/v1/instance_packages_spec.rb` | Same |
| `spec/requests/api/conan/v2/project_packages_spec.rb` | Same |
| `ee/spec/requests/api/conan/v1/project_packages_spec.rb` | Same |
| `spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb` | Add shared examples for Artifactory error response validation |
## :rotating_light: Error scenarios in Conan API
### :arrows_counterclockwise: Shared endpoints (`lib/api/concerns/packages/conan/shared_endpoints.rb`)
| Status | Trigger | Expected `message` |
|---|---|---|
| 400 | `ActiveRecord::RecordInvalid` (package validation) | Validation message (e.g., `"Validation failed: ..."`) |
| 400 | `bad_request!(response.message)` (search error) | `"Search term length must be less than 200 characters"` or `"Too many wildcards in search term. Maximum is 5"` |
| 401 | `unauthorized!` (no valid token on `/users/authenticate`) | `"Unauthorized"` |
| 404 | `not_found!` (FIPS mode enabled) | `"Not Found"` |
| 404 | `not_found!('Package')` (search by reference) | `"Package Not Found"` |
### :one: V1 endpoints (`lib/api/concerns/packages/conan/v1_endpoints.rb`)
| Status | Trigger | Expected `message` |
|---|---|---|
| 400 | `bad_request!('File is too large')` | `"File is too large"` |
| 400 | `bad_request!(nil)` (invalid JSON body) | `"Bad request"` |
| 400 | `bad_request!(service_response.message)` (package/file creation error) | Service error message |
| 403 | `forbidden!(service_response.message)` (protected package on create) | Protection error message |
| 403 | `forbidden!('Package protected.')` (via `protect_package!`) | `"Package protected."` |
| 403 | `forbidden!` (remote storage error) | `"Forbidden"` |
| 404 | `not_found!('Package')` (download, package not found) | `"Package Not Found"` |
| 404 | `not_found!('Non checksum storage')` (checksum deploy header) | `"Non checksum storage Not Found"` |
| 404 | `render_api_error!("No recipe manifest found", 404)` | `"No recipe manifest found"` |
### :two: V2 endpoints (`lib/api/conan/v2/project_packages.rb`)
| Status | Trigger | Expected `message` |
|---|---|---|
| 400 | Same as V1 upload errors | Same as V1 |
| 403 | Same as V1 upload errors | Same as V1 |
| 404 | `not_found!('Package')` (various endpoints) | `"Package Not Found"` |
| 404 | `not_found!('Revision')` (recipe revision not found) | `"Revision Not Found"` |
| 404 | `not_found!('Recipe files')` (no files for revision) | `"Recipe files Not Found"` |
| 404 | `not_found!('Package files')` (no package files) | `"Package files Not Found"` |
| 404 | `not_found!('Package Revision')` (package revision not found) | `"Package Revision Not Found"` |
| 422 | `unprocessable_entity!("Cannot delete more than 1000 files")` | `"Cannot delete more than 1000 files"` |
### :lock: Authorization errors (implicit via `route_setting`)
| Status | Trigger | Expected `message` |
|---|---|---|
| 401 | Invalid/missing/expired credentials | `"Unauthorized"` |
| 403 | Insufficient permissions (read/upload/delete) | `"Forbidden"` |
## :white_check_mark: Verification plan
### :globe_with_meridians: curl verification (GDK)
```bash
# Enable feature flag
# Rails console: Feature.enable(:conan_artifactory_error_responses)
# 401 - no token
curl -v "http://gdk.test:3000/api/v4/projects/1/packages/conan/v1/users/authenticate"
# Expected: {"errors":[{"status":401,"message":"Unauthorized"}]}
# 403 - protected package upload (V1)
curl -v -X PUT -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/1/packages/conan/v1/files/pkg/1.0/user/channel/0/export/conanfile.py/authorize"
# Expected: {"errors":[{"status":403,"message":"Package protected."}]}
# 404 - nonexistent package (V1)
curl -v -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/1/packages/conan/v1/conans/nonexistent/1.0/user/channel/search"
# Expected: {"errors":[{"status":404,"message":"Package Not Found"}]}
# 404 - nonexistent package (V2)
curl -v -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/1/packages/conan/v2/conans/nonexistent/1.0/user/channel/latest"
# Expected: {"errors":[{"status":404,"message":"Package Not Found"}]}
# 400 - search term too long
curl -v -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/1/packages/conan/v1/conans/search?q=$(python3 -c 'print("a"*201)')"
# Expected: {"errors":[{"status":400,"message":"Search term length must be less than 200 characters"}]}
# CRITICAL: verify Content-Type is application/json (no charset parameter for v1 < 1.37 compat)
curl -sI -H "Private-Token: invalid" \
"http://gdk.test:3000/api/v4/projects/1/packages/conan/v1/conans/search?q=test" \
| grep -i content-type
# Should be: Content-Type: application/json
# Verify feature flag OFF returns old format
# Rails console: Feature.disable(:conan_artifactory_error_responses)
curl -s "http://gdk.test:3000/api/v4/projects/1/packages/conan/v1/users/authenticate"
# Expected: {"message":"401 Unauthorized"}
```
### :package: Client versions to test
| Client | Version | Purpose |
|---|---|---|
| Conan | **v2 (latest)** | Primary test target — full `response_to_str()` support |
| Conan | **v2.3.0** | First version with 401 body passthrough in FileDownloader |
| Conan | v1 (latest 1.x) | Verify backward compatibility with v1 `response_to_str()` |
### :test_tube: Test scenarios
For each client version, test with feature flag **enabled** and **disabled**:
1. **Authentication failure** (401) — wrong/missing token on `conan remote login`; verify error detail displayed
2. **Permission denied** (403) — token without write permission; `conan upload` to project
3. **Protected package** (403) — `conan upload` to protected package; verify `"Package protected."` message
4. **Package not found** (404) — `conan install` nonexistent package reference
5. **Upload too large** (400) — upload file exceeding size limit
6. **Search error** (400) — search with term > 200 characters
### :crystal_ball: Expected output
**Conan v2 with flag enabled:**
```
ERROR: 403 Forbidden. Remote: 403: Package protected.
```
**Conan v2 with flag disabled (current behavior):**
```
ERROR: 403 Forbidden. Remote: {"message": "403 Forbidden - Package protected."}
```
**Conan v1 with flag enabled:**
```
ERROR: 403 Forbidden. Remote: 403: Package protected.
```
The behavior improvement is identical for v1 and v2 since both use the same `response_to_str()` logic. The only difference is that v1 FileDownloader doesn't pass through 401 bodies on download — but downloads rarely produce 401s in practice (authentication happens earlier in the flow).
## :link: References
- Parent issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/595485
- [Artifactory REST API errors format](https://jfrog.com/help/r/jfrog-rest-apis/errors)
- [Conan `response_to_str` source](https://github.com/conan-io/conan/blob/develop2/conans/client/rest/__init__.py)
- [conan-io/conan#15983](https://github.com/conan-io/conan/pull/15983) — 401 error body passthrough
- [conan-io/conan#8884](https://github.com/conan-io/conan/issues/8884) — Content-Type charset handling
- [conan-io/conan#6193](https://github.com/conan-io/conan/issues/6193) — 403 error handling
task