Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions docs/boost-endpoint-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,25 +141,24 @@ See [Request reference](#request-reference) for the full body schema.

```json
{
"type": "validation_error",
"errors": [
{
"code": "required_field",
"message": "This field is required.",
"metadata": {"field": "organization", "drf_code": "required"}
"detail": "This field is required.",
"attr": "organization"
},
{
"code": "invalid_submodule_list",
"message": "Expected a list of items but got type \"str\".",
"metadata": {
"field": "add_or_update",
"language": "zh_Hans",
"drf_code": "not_a_list"
}
"detail": "Expected a list of items but got type \"str\".",
"attr": "add_or_update.zh_Hans"
}
]
}
```

HTTP validation errors follow Weblate's [DRF standardized error format](https://drf-standardized-errors.readthedocs.io/en/latest/error_response.html): top-level `type` is `"validation_error"`, and each entry has `code`, `detail`, and `attr` (the serializer field path, or `null` for non-field errors). Stable `BoostEndpointErrorCode` values appear in `code`.

---

## Request reference
Expand Down Expand Up @@ -214,7 +213,8 @@ This processes the `json` and `unordered` submodules for Simplified Chinese, and

| Field | Type | Description |
|-------|------|-------------|
| `errors` | array | Unified list of structured error objects (see [Error handling](#error-handling)) |
| `type` | string | Always `"validation_error"` for request-body validation failures |
| `errors` | array | DRF standardized validation entries (`code`, `detail`, `attr`); see [Error handling](#error-handling) |

### 401 Unauthorized

Expand Down Expand Up @@ -250,7 +250,8 @@ The Celery task (`boost_add_or_update_task`) returns a dictionary keyed by langu
"errors": [
{
"code": "clone_failed",
"message": "Failed to clone repository for unordered",
"detail": "Failed to clone repository for unordered",
"attr": null,
"metadata": {
"submodule": "unordered",
"organization": "boostorg",
Expand Down Expand Up @@ -284,7 +285,7 @@ The Celery task (`boost_add_or_update_task`) returns a dictionary keyed by langu
| `components_updated` | integer | Existing components whose push branch was refreshed |
| `components_failed` | integer | Components where `create_or_update_component` returned `None` |
| `components_deleted` | integer | Components removed because they were no longer found in the repo scan |
| `errors` | array of objects | Non-fatal structured errors (`code`, `message`, `metadata`); see [Error handling](#error-handling) |
| `errors` | array of objects | Non-fatal structured errors (`code`, `detail`, `attr`, optional `metadata`); see [Error handling](#error-handling) |

---

Expand Down Expand Up @@ -381,24 +382,38 @@ For each submodule the following steps run in order:

## Error handling

All Boost endpoint errors share one JSON-serializable shape (`src/boost_weblate/endpoint/errors.py`):
Boost endpoint errors use stable `BoostEndpointErrorCode` values in `code`. The JSON shape depends on the layer:

### HTTP validation (`400 Bad Request`)

Produced by Weblate's DRF exception handler when `AddOrUpdateRequestSerializer` rejects the request body.

| Field | Type | Description |
|-------|------|-------------|
| `type` | string | `"validation_error"` |
| `errors` | array | Entries with `code`, `detail`, and `attr` (field path such as `organization` or `add_or_update.zh_Hans`, or `null`) |

No `metadata` bag on HTTP validation entries. Field location is expressed via `attr`.

### Service / submodule results

Recoverable failures inside `BoostComponentService` are appended to per-submodule `errors` lists in the Celery task return value via `to_error_dict()` (`src/boost_weblate/endpoint/errors.py`).

| Field | Type | Description |
|-------|------|-------------|
| `code` | string | Stable machine-readable identifier (see table below) |
| `message` | string | Human-readable description |
| `metadata` | object | Context for monitoring, retry logic, and client handling |
| `detail` | string | Human-readable description |
| `attr` | string or null | Related request field when applicable; usually `null` for service failures |
| `metadata` | object | Optional context (e.g. `submodule`, `organization`, `permission`, `timeout_seconds`) |

### Where errors appear

| Layer | HTTP status / Celery state | Shape |
|-------|---------------------------|-------|
| Request validation | `400 Bad Request` | `{"errors": [<error>, ...]}` |
| Recoverable submodule failure | `202` accepted; task `SUCCESS` with partial failures | `submodule_results[].errors: [<error>, ...]` |
| Request validation | `400 Bad Request` | `{"type": "validation_error", "errors": [{"code", "detail", "attr"}, ...]}` |
| Recoverable submodule failure | `202` accepted; task `SUCCESS` with partial failures | `submodule_results[].errors: [{"code", "detail", "attr", "metadata"?}, ...]` |
| Fatal task failure | Task `FAILURE` | `BoostEndpointError` exception with `.code` and `.metadata` |

HTTP `400` responses and submodule `errors` lists use the same object schema. Validation errors may include `metadata.field`, `metadata.language`, and `metadata.drf_code` (the original DRF `ErrorDetail.code` when applicable).

### Error codes

| Code | Typical source |
Expand Down
119 changes: 106 additions & 13 deletions src/boost_weblate/endpoint/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,39 +50,132 @@ def __init__(
self.metadata = dict(metadata or {})

def to_dict(self) -> dict[str, Any]:
return {
"code": self.code.value,
"message": str(self.args[0]),
"metadata": self.metadata,
}
field = self.metadata.get("field")
language = self.metadata.get("language")
return to_error_entry(
self.code.value,
str(self.args[0]),
attr=attr_for_field(field, language=language),
)


def attr_for_field(
field: str | None,
*,
language: str | None = None,
) -> str | None:
"""Map request field / language metadata to a DRF ``attr`` path."""
if field is None:
return None
if language:
return f"{field}.{language}"
return field

Comment thread
whisper67265 marked this conversation as resolved.

def _error_code_value(code: BoostEndpointErrorCode | str) -> str:
code_str = code.value if isinstance(code, BoostEndpointErrorCode) else str(code)
try:
return BoostEndpointErrorCode(code_str).value
except ValueError:
return code_str


def to_error_entry(
code: BoostEndpointErrorCode | str,
detail: str,
*,
attr: str | None = None,
) -> dict[str, Any]:
"""Build a strict DRF standardized error entry (no metadata bag)."""
return {
"code": _error_code_value(code),
"detail": detail,
"attr": attr,
}


def to_error_dict(
code: BoostEndpointErrorCode | str,
message: str,
detail: str,
*,
attr: str | None = None,
**metadata: Any,
) -> dict[str, Any]:
"""Build a JSON-serializable error dict without raising."""
return BoostEndpointError(message, code=code, metadata=metadata).to_dict()
"""Build a JSON-serializable service-layer error dict without raising."""
entry = to_error_entry(code, detail, attr=attr)
if metadata:
entry["metadata"] = metadata
return entry


def append_error(
result: dict[str, Any],
code: BoostEndpointErrorCode | str,
message: str,
detail: str,
**metadata: Any,
) -> None:
"""Append a structured error to a service result's ``errors`` list."""
result.setdefault("errors", []).append(to_error_dict(code, message, **metadata))
field = metadata.pop("field", None)
language = metadata.pop("language", None)
result.setdefault("errors", []).append(
to_error_dict(
code,
detail,
attr=attr_for_field(field, language=language),
**metadata,
)
)


def boost_validation_errors(
items: list[tuple[BoostEndpointErrorCode | str, str, dict[str, Any]]],
) -> list[dict[str, Any]]:
"""Build a unified error list from validation failure tuples."""
return [
to_error_dict(code, message, **metadata) for code, message, metadata in items
]
errors: list[dict[str, Any]] = []
for code, detail, item_metadata in items:
meta = dict(item_metadata)
field = meta.pop("field", None)
language = meta.pop("language", None)
errors.append(
to_error_dict(
code,
detail,
attr=attr_for_field(field, language=language),
**meta,
)
)
return errors


def _message_and_code(err: Any) -> tuple[str, str]:
code = getattr(err, "code", BoostEndpointErrorCode.REQUIRED_FIELD.value)
return str(err), str(code)


def flatten_validation_detail(detail: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten field-keyed validation detail into DRF standardized error entries."""
results: list[dict[str, Any]] = []

def walk(messages: Any, attr: str | None = None) -> None:
if isinstance(messages, dict) or hasattr(messages, "items"):
for key, value in messages.items():
key_str = str(key)
nested_attr = f"{attr}.{key_str}" if attr else key_str
walk(value, nested_attr)
return
if isinstance(messages, (list, tuple)):
for msg in messages:
if isinstance(msg, dict) or hasattr(msg, "items"):
walk(msg, attr)
else:
message, code = _message_and_code(msg)
results.append(to_error_entry(code, message, attr=attr))
return
message, code = _message_and_code(messages)
results.append(to_error_entry(code, message, attr=attr))

walk(detail)
return results


def wrap_task_error(exc: BaseException) -> BoostEndpointError:
Expand Down
Loading
Loading