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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ WEBLATE_SERVER_EMAIL=noreply@example.com
WEBLATE_DEFAULT_FROM_EMAIL=noreply@example.com
WEBLATE_MIN_PASSWORD_SCORE=0
WEBLATE_ALLOWED_HOSTS=weblate.example.com
WEBLATE_REGISTRATION_OPEN=0
WEBLATE_REGISTRATION_OPEN=1
WEBLATE_TIME_ZONE=UTC

# Subpath when nginx serves Weblate at https://<host>/weblate/ (see WEBLATE_URL_PREFIX).
Expand Down
88 changes: 75 additions & 13 deletions docs/boost-endpoint-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,22 @@ See [Request reference](#request-reference) for the full body schema.

```json
{
"errors": {
"organization": ["This field is required."],
"add_or_update": {"": ["This field is required."]}
}
"errors": [
{
"code": "required_field",
"message": "This field is required.",
"metadata": {"field": "organization", "drf_code": "required"}
},
{
"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"
}
}
]
}
```

Expand Down Expand Up @@ -211,7 +223,7 @@ This processes the `json` and `unordered` submodules for Simplified Chinese, and

| Field | Type | Description |
|-------|------|-------------|
| `errors` | object | DRF serializer error map: field name → list of error strings |
| `errors` | array | Unified list of structured error objects (see [Error handling](#error-handling)) |

### 401 Unauthorized

Expand Down Expand Up @@ -239,12 +251,22 @@ The Celery task (`boost_add_or_update_task`) returns a dictionary keyed by langu
},
{
"submodule": "unordered",
"success": true,
"components_created": 2,
"components_updated": 1,
"success": false,
"components_created": 0,
"components_updated": 0,
"components_failed": 0,
"components_deleted": 0,
"errors": []
"errors": [
{
"code": "clone_failed",
"message": "Failed to clone repository for unordered",
"metadata": {
"submodule": "unordered",
"organization": "boostorg",
"lang_code": "zh_Hans"
}
}
]
}
]
},
Expand All @@ -271,7 +293,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 strings | Non-fatal error messages (clone failure, permission denial, git errors) |
| `errors` | array of objects | Non-fatal structured errors (`code`, `message`, `metadata`); see [Error handling](#error-handling) |

---

Expand Down Expand Up @@ -303,7 +325,7 @@ The task uses Weblate's own Celery `app` instance (`weblate.utils.celery.app`) a

`user_id` (an integer primary key) is passed rather than the user object itself because Celery serializes task arguments to JSON. The task re-fetches the user with `User.objects.get(pk=user_id)` inside the worker.

Exceptions raised by `BoostComponentService` propagate out of the task function unhandled, causing Celery to mark the task `FAILURE`. Per-submodule errors that are recoverable (e.g. clone failure, permission denial for a single component) are collected into the `errors` list and do not raise exceptions.
Fatal task failures raise `BoostEndpointError` (a `WeblateError` subclass from `weblate.trans.exceptions`) with a stable `code` and `metadata`, causing Celery to mark the task `FAILURE`. Examples: `task_user_not_found` when the `user_id` no longer exists, or `task_internal_error` for unexpected exceptions (after `report_error()`). Per-submodule errors that are recoverable (e.g. clone failure, permission denial for a single submodule) are collected into the submodule `errors` list as structured objects and do not raise exceptions.

`trail=False` is set on the task to suppress Celery's default task-result trail and avoid unbounded result-backend growth in long-running deployments.

Expand Down Expand Up @@ -366,9 +388,49 @@ For each submodule the following steps run in order:

## Error handling

The service uses a non-fatal error collection strategy: individual failures are appended to the `errors` list in the submodule result and processing continues with the next item. The `success` flag is `false` only when every component in the submodule failed or the clone itself failed.
All Boost endpoint errors share one JSON-serializable shape (`src/boost_weblate/endpoint/errors.py`):

Exceptions that escape `process_submodule` or `process_all` propagate to the Celery task, which lets them surface as a `FAILURE` state so monitoring systems can alert. Internal exceptions within `create_or_update_component`, `add_language_to_component`, and `_delete_component_and_commit_removal` are caught, logged via Weblate's `LOGGER`, reported to Weblate's error-tracking system via `report_error()`, and reflected as incremented failure counters or appended error strings.
| 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 |

### 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>, ...]` |
| 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 |
|------|----------------|
| `required_field` | Missing `organization`, `version`, or `add_or_update`; empty `add_or_update` dict |
| `invalid_language_code` | Non-string or blank language key in `add_or_update` |
| `invalid_submodule_list` | Submodule value is not a non-empty list |
| `invalid_submodule` | Submodule name fails path validation |
| `clone_failed` | `git clone` failed or timed out |
| `no_documentation_files` | No supported doc files found after scan |
| `permission_denied` | Missing `project.add` or `project.edit` |
| `project_create_failed` | Project `get_or_create` raised |
| `component_delete_failed` | Stale component deletion failed |
| `file_remove_failed` | Translation file removal from disk failed |
| `git_push_failed` | Git status, commit, or push failed |
| `git_push_timeout` | Git commit/push subprocess timeout |
| `all_components_failed` | Every scanned component failed create/update |
| `task_user_not_found` | Celery task `user_id` not found |
| `task_internal_error` | Unexpected exception in the Celery task |

### Recoverable vs fatal

The service uses a non-fatal error collection strategy: individual submodule failures are appended to `errors` and processing continues with the next submodule. The `success` flag is `false` when no component was created or updated for that submodule (including clone failure).

Internal exceptions within `create_or_update_component`, `add_language_to_component`, and `_delete_component_and_commit_removal` are caught, logged via Weblate's `LOGGER`, reported via `report_error()`, and reflected as incremented failure counters or structured entries in `errors`. Unexpected exceptions that escape `process_all` are wrapped as `BoostEndpointError` in the Celery task.

---

Expand Down
93 changes: 93 additions & 0 deletions src/boost_weblate/endpoint/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# SPDX-FileCopyrightText: 2026 Andrew Zhang <whisper67265@outlook.com>
#
# SPDX-License-Identifier: BSL-1.0

"""Structured error taxonomy for the Boost documentation translation API."""

from __future__ import annotations

from enum import StrEnum
from typing import Any

from weblate.trans.exceptions import WeblateError


class BoostEndpointErrorCode(StrEnum):
"""Stable machine-readable error codes for Boost endpoint failures."""

INVALID_SUBMODULE = "invalid_submodule"
CLONE_FAILED = "clone_failed"
NO_DOCUMENTATION_FILES = "no_documentation_files"
PERMISSION_DENIED = "permission_denied"
PROJECT_CREATE_FAILED = "project_create_failed"
COMPONENT_DELETE_FAILED = "component_delete_failed"
FILE_REMOVE_FAILED = "file_remove_failed"
GIT_PUSH_FAILED = "git_push_failed"
GIT_PUSH_TIMEOUT = "git_push_timeout"
ALL_COMPONENTS_FAILED = "all_components_failed"
INVALID_LANGUAGE_CODE = "invalid_language_code"
INVALID_SUBMODULE_LIST = "invalid_submodule_list"
REQUIRED_FIELD = "required_field"
TASK_USER_NOT_FOUND = "task_user_not_found"
TASK_INTERNAL_ERROR = "task_internal_error"


class BoostEndpointError(WeblateError):
"""Structured Boost endpoint error (subclass of Weblate's base error type)."""

def __init__(
self,
message: str,
*,
code: BoostEndpointErrorCode | str,
metadata: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = BoostEndpointErrorCode(code)
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,
}


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


def append_error(
result: dict[str, Any],
code: BoostEndpointErrorCode | str,
message: 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))


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
]


def wrap_task_error(exc: BaseException) -> BoostEndpointError:
"""Wrap an unexpected task exception as a structured BoostEndpointError."""
if isinstance(exc, BoostEndpointError):
return exc
return BoostEndpointError(
str(exc),
code=BoostEndpointErrorCode.TASK_INTERNAL_ERROR,
metadata={"exception_type": type(exc).__name__},
)
Loading
Loading