fronta 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fronta/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """Fronta: distributed task processing on PostgreSQL with sandboxed process execution."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from fronta.config import Settings
6
+ from fronta.definitions import Context, ProcessTaskDefinition, TaskDefinition, process_task, task
7
+ from fronta.errors import (
8
+ ConfigurationError,
9
+ FrontaError,
10
+ InputValidationError,
11
+ InvalidInput,
12
+ NonRetryableError,
13
+ NotCancellable,
14
+ PayloadTooLarge,
15
+ ProgressTooLarge,
16
+ ResultSerializationError,
17
+ SandboxError,
18
+ TaskNotFound,
19
+ UnknownTaskType,
20
+ )
21
+ from fronta.model import Backoff, Policy, Sandbox, State, TaskRow, TaskSummary, TaskTypeRow
22
+ from fronta.runtime import close_pool, configure, open_pool
23
+ from fronta.worker import Worker
24
+
25
+ __version__: str = version("fronta")
26
+
27
+ __all__ = [
28
+ "Backoff",
29
+ "ConfigurationError",
30
+ "Context",
31
+ "FrontaError",
32
+ "InputValidationError",
33
+ "InvalidInput",
34
+ "NonRetryableError",
35
+ "NotCancellable",
36
+ "PayloadTooLarge",
37
+ "Policy",
38
+ "ProcessTaskDefinition",
39
+ "ProgressTooLarge",
40
+ "ResultSerializationError",
41
+ "Sandbox",
42
+ "SandboxError",
43
+ "Settings",
44
+ "State",
45
+ "TaskDefinition",
46
+ "TaskNotFound",
47
+ "TaskRow",
48
+ "TaskSummary",
49
+ "TaskTypeRow",
50
+ "UnknownTaskType",
51
+ "Worker",
52
+ "__version__",
53
+ "close_pool",
54
+ "configure",
55
+ "open_pool",
56
+ "process_task",
57
+ "task",
58
+ ]
fronta/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """`fronta` command line: `db init`, `worker`, `server`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib
7
+ import logging
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import click
13
+ import psycopg
14
+ from pydantic import ValidationError
15
+
16
+ from fronta import store
17
+ from fronta.config import Settings
18
+ from fronta.errors import ConfigurationError
19
+ from fronta.worker import Worker
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ SERVER_EXTRA_MODULES = frozenset({"fastapi", "starlette", "uvicorn", "jinja2", "mcp", "jsonschema"})
24
+ """Top-level modules provided by the `server` extra."""
25
+
26
+
27
+ def configure_logging() -> None:
28
+ """Our loggers follow `LOG_LEVEL_OURS` (default INFO), libraries `LOG_LEVEL_LIBS` (WARNING)."""
29
+ logging.basicConfig(
30
+ level=os.environ.get("LOG_LEVEL_LIBS", "WARNING").upper(),
31
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
32
+ stream=sys.stderr,
33
+ )
34
+ logging.getLogger("fronta").setLevel(os.environ.get("LOG_LEVEL_OURS", "INFO").upper())
35
+
36
+
37
+ def load_object(target: str, expected: type) -> object:
38
+ """Import `module:attr` (the current directory is importable, as with uvicorn)."""
39
+ module_name, sep, attr = target.partition(":")
40
+ if not sep or not module_name or not attr:
41
+ msg = f"expected module:attr, got {target!r}"
42
+ raise click.BadParameter(msg)
43
+ cwd = str(Path.cwd())
44
+ if cwd not in sys.path:
45
+ sys.path.insert(0, cwd)
46
+ try:
47
+ module = importlib.import_module(module_name)
48
+ except ImportError as exc:
49
+ msg = f"cannot import {module_name!r}: {exc}"
50
+ raise click.BadParameter(msg) from exc
51
+ try:
52
+ obj = getattr(module, attr)
53
+ except AttributeError as exc:
54
+ msg = f"{module_name!r} has no attribute {attr!r}"
55
+ raise click.BadParameter(msg) from exc
56
+ if not isinstance(obj, expected):
57
+ msg = f"{target} is {type(obj).__name__}, expected {expected.__name__}"
58
+ raise click.BadParameter(msg)
59
+ return obj
60
+
61
+
62
+ @click.group()
63
+ @click.version_option(package_name="fronta")
64
+ def main() -> None:
65
+ """Fronta: distributed task processing on PostgreSQL."""
66
+
67
+
68
+ @main.group()
69
+ def db() -> None:
70
+ """Database administration."""
71
+
72
+
73
+ @db.command("init")
74
+ @click.option("--dsn", envvar="FRONTA_DSN", required=True, help="PostgreSQL DSN (or FRONTA_DSN).")
75
+ def db_init(dsn: str) -> None:
76
+ """Create the `fronta` schema (idempotent)."""
77
+
78
+ async def run() -> None:
79
+ async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as conn:
80
+ await store.init_schema(conn)
81
+
82
+ try:
83
+ asyncio.run(run())
84
+ except psycopg.Error as exc:
85
+ raise click.ClickException(f"database error: {exc}") from exc
86
+ click.echo("fronta schema is ready")
87
+
88
+
89
+ @main.command()
90
+ @click.argument("target")
91
+ def worker(target: str) -> None:
92
+ """Run the worker TARGET (`module:attr`, a `fronta.Worker`) until SIGTERM/SIGINT."""
93
+ configure_logging()
94
+ instance = load_object(target, Worker)
95
+ assert isinstance(instance, Worker) # noqa: S101 # narrowed by load_object
96
+ try:
97
+ settings = instance.settings # FRONTA_* is read here, not when the module was imported
98
+ except ValidationError as exc:
99
+ raise click.ClickException(f"invalid settings: {exc}") from exc
100
+ log.info("worker target %s, concurrency %d", target, settings.concurrency)
101
+ try:
102
+ sys.exit(asyncio.run(instance.run()))
103
+ except psycopg.OperationalError as exc: # could not reach the database at start
104
+ raise click.ClickException(f"database unavailable: {exc}") from exc
105
+
106
+
107
+ @main.command()
108
+ @click.option(
109
+ "--host", default=None, help="Bind address (default FRONTA_SERVER_HOST or 127.0.0.1)."
110
+ )
111
+ @click.option("--port", default=None, type=int, help="Port (default FRONTA_SERVER_PORT or 8000).")
112
+ def server(host: str | None, port: int | None) -> None:
113
+ """Serve the REST API, the MCP endpoint and the dashboard (needs `fronta[server]`)."""
114
+ configure_logging()
115
+ try:
116
+ from fronta.server import serve # noqa: PLC0415 # optional dependency, loaded on demand
117
+ except ImportError as exc:
118
+ if (exc.name or "").split(".")[0] not in SERVER_EXTRA_MODULES:
119
+ raise # a genuine broken import, not a missing extra
120
+ msg = f"the server needs the optional dependencies: pip install 'fronta[server]' ({exc})"
121
+ raise click.ClickException(msg) from exc
122
+ overrides = {
123
+ k: v for k, v in {"server_host": host, "server_port": port}.items() if v is not None
124
+ }
125
+ try:
126
+ settings = Settings() # type: ignore[call-arg] # pydantic-settings reads FRONTA_DSN
127
+ if overrides:
128
+ settings = Settings(**{**settings.model_dump(), **overrides}) # re-validated
129
+ except ValidationError as exc:
130
+ raise click.ClickException(f"invalid settings: {exc}") from exc
131
+ try:
132
+ serve(settings)
133
+ except ConfigurationError as exc:
134
+ raise click.ClickException(str(exc)) from exc
fronta/codec.py ADDED
@@ -0,0 +1,111 @@
1
+ """JSON encoding with byte caps and error-metadata shaping.
2
+
3
+ Caps count UTF-8 bytes of the compact JSON encoding (`separators=(",", ":")`, non-ASCII kept as is).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import traceback
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from fronta.model import JSON
14
+
15
+ _JSON_KW: dict[str, Any] = {"separators": (",", ":"), "ensure_ascii": False, "allow_nan": False}
16
+
17
+
18
+ class Unstorable(ValueError):
19
+ """The value cannot live in a `jsonb` column (NUL characters, NaN, infinity)."""
20
+
21
+
22
+ class OverCap(ValueError):
23
+ """The encoding exceeds its byte cap."""
24
+
25
+
26
+ def encode(value: JSON) -> str:
27
+ """Compact JSON.
28
+
29
+ Raises `Unstorable` for NaN/inf and for NUL characters (which `jsonb` rejects), and
30
+ `TypeError` for anything that is not JSON: unknown objects and non-string mapping keys
31
+ (`json.dumps` would silently turn `{1: ...}` into `{"1": ...}`).
32
+ """
33
+ _check(value)
34
+ try:
35
+ return json.dumps(value, **_JSON_KW)
36
+ except ValueError as exc:
37
+ raise Unstorable(str(exc)) from exc
38
+
39
+
40
+ def _check(value: object) -> None:
41
+ if isinstance(value, str):
42
+ if "\x00" in value:
43
+ msg = "NUL characters cannot be stored in jsonb"
44
+ raise Unstorable(msg)
45
+ elif isinstance(value, dict):
46
+ for key, item in value.items():
47
+ if not isinstance(key, str):
48
+ msg = f"mapping keys must be strings, got {type(key).__name__}"
49
+ raise TypeError(msg)
50
+ _check(key)
51
+ _check(item)
52
+ elif isinstance(value, list | tuple):
53
+ for item in value:
54
+ _check(item)
55
+
56
+
57
+ def sanitize(text: str) -> str:
58
+ """Make text storable: NUL is the one code point `jsonb` rejects."""
59
+ return text.replace("\x00", "\ufffd")
60
+
61
+
62
+ def utf8_len(text: str) -> int:
63
+ return len(text.encode("utf-8"))
64
+
65
+
66
+ def encode_capped(value: JSON, cap: int, what: str) -> str:
67
+ """Encode and enforce the cap. Raises `OverCap` with a message naming `what`."""
68
+ text = encode(value)
69
+ size = utf8_len(text)
70
+ if size > cap:
71
+ msg = f"{what} is {size} bytes, cap is {cap} bytes"
72
+ raise OverCap(msg)
73
+ return text
74
+
75
+
76
+ def error_metadata(exc: BaseException, cap: int) -> dict[str, Any]:
77
+ """Structured error for a failed attempt: type, message, traceback — truncated to `cap`."""
78
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
79
+ return truncate(
80
+ {"type": type(exc).__qualname__, "message": sanitize(str(exc)), "traceback": sanitize(tb)},
81
+ cap,
82
+ keep_tail=("traceback",),
83
+ )
84
+
85
+
86
+ def truncate(error: dict[str, Any], cap: int, keep_tail: tuple[str, ...] = ()) -> dict[str, Any]:
87
+ """Shrink the longest string fields until the encoding fits `cap`; marks `truncated: true`.
88
+
89
+ Fields in `keep_tail` keep their end (the useful part of a traceback or a log stream); other
90
+ fields keep their start. Slicing happens on code points, so the output stays valid UTF-8.
91
+ Always terminates: a field that cannot shrink further is emptied, and when nothing is left
92
+ to cut only the type survives.
93
+ """
94
+ data = dict(error)
95
+ if utf8_len(encode(data)) <= cap:
96
+ return data
97
+ data["truncated"] = True
98
+ while utf8_len(encode(data)) > cap:
99
+ name, text = max(
100
+ ((k, v) for k, v in data.items() if isinstance(v, str) and v),
101
+ key=lambda kv: len(kv[1]),
102
+ default=(None, ""),
103
+ )
104
+ if name is None:
105
+ return {"type": str(error.get("type", ""))[:64], "truncated": True}
106
+ keep = len(text) // 2
107
+ if keep == 0:
108
+ data[name] = ""
109
+ else:
110
+ data[name] = text[-keep:] if name in keep_tail else text[:keep]
111
+ return data
fronta/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Typed configuration: parsed once from `FRONTA_*` environment variables, validated at startup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field, model_validator
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+ KIB = 1024
9
+ MIB = 1024 * KIB
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ """Runtime settings. Durations are seconds; caps are UTF-8 bytes of the JSON encoding."""
14
+
15
+ model_config = SettingsConfigDict(env_prefix="FRONTA_", extra="ignore", frozen=True)
16
+
17
+ dsn: str = Field(min_length=1, description="PostgreSQL connection string")
18
+
19
+ # Liveness
20
+ lease_s: float = Field(30.0, gt=0)
21
+ heartbeat_s: float = Field(10.0, gt=0)
22
+ reaper_interval_s: float = Field(15.0, gt=0)
23
+ poll_interval_s: float = Field(5.0, gt=0)
24
+ grace_s: float = Field(30.0, ge=0)
25
+ kill_timeout_s: float = Field(5.0, gt=0)
26
+
27
+ # Worker
28
+ concurrency: int = Field(10, ge=1)
29
+ pool_size: int = Field(4, ge=1)
30
+ connect_timeout_s: float = Field(10.0, ge=1)
31
+ statement_timeout_s: float = Field(30.0, ge=0.001)
32
+
33
+ # Retention
34
+ retention_s: float = Field(7 * 86400.0, ge=0)
35
+ purge_interval_s: float = Field(600.0, gt=0)
36
+ purge_batch: int = Field(1000, ge=1)
37
+
38
+ # Caps
39
+ payload_cap: int = Field(MIB, ge=KIB)
40
+ result_cap: int = Field(MIB, ge=KIB)
41
+ progress_cap: int = Field(64 * KIB, ge=64)
42
+ error_cap: int = Field(64 * KIB, ge=KIB)
43
+ list_page_size: int = Field(50, ge=1)
44
+ list_page_max: int = Field(200, ge=1)
45
+
46
+ # Server
47
+ server_host: str = "127.0.0.1"
48
+ server_port: int = Field(8000, ge=1, le=65535)
49
+ server_token: str | None = Field(None, min_length=1) # an empty secret is a misconfiguration
50
+
51
+ # Sandbox
52
+ bwrap_path: str = "bwrap"
53
+
54
+ @model_validator(mode="after")
55
+ def _check_relations(self) -> Settings:
56
+ if self.heartbeat_s >= self.lease_s:
57
+ msg = f"heartbeat_s ({self.heartbeat_s}) must be shorter than lease_s ({self.lease_s})"
58
+ raise ValueError(msg)
59
+ if self.list_page_size > self.list_page_max:
60
+ msg = "list_page_size must not exceed list_page_max"
61
+ raise ValueError(msg)
62
+ return self
fronta/definitions.py ADDED
@@ -0,0 +1,229 @@
1
+ """Task definitions: the `task` / `process_task` decorators, `Sandbox`, and `enqueue`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from datetime import datetime, timedelta
7
+ from typing import TYPE_CHECKING, Any, Protocol
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from fronta import codec, runtime, store
12
+ from fronta.errors import PayloadTooLarge
13
+ from fronta.model import Backoff, Executor, NewTask, Policy, Sandbox, TaskTypeSpec
14
+
15
+ if TYPE_CHECKING:
16
+ import asyncio
17
+ import logging
18
+
19
+ from fronta.model import JSON
20
+
21
+
22
+ class Context[StateT](Protocol):
23
+ """What a handler receives. Implemented by the worker."""
24
+
25
+ task_id: int
26
+ attempt: int
27
+ state: StateT
28
+ log: logging.LoggerAdapter[logging.Logger]
29
+ cancelled: asyncio.Event
30
+
31
+ async def progress(self, value: JSON) -> None:
32
+ """Store progress (any JSON value up to the progress cap; over-cap raises)."""
33
+
34
+ async def enqueue[I: BaseModel]( # noqa: PLR0913 # public signature fixed by SPEC.md
35
+ self,
36
+ task: TaskDefinition[I, Any],
37
+ input: I,
38
+ *,
39
+ priority: int = 0,
40
+ run_at: datetime | None = None,
41
+ key: str | None = None,
42
+ concurrency_key: str | None = None,
43
+ ) -> int:
44
+ """Enqueue another task, immediately and independently of this task's outcome."""
45
+
46
+
47
+ type Handler[InputT: BaseModel, OutputT] = Callable[[Context[Any], InputT], Awaitable[OutputT]]
48
+
49
+
50
+ def _seconds(value: float | timedelta) -> float:
51
+ return value.total_seconds() if isinstance(value, timedelta) else float(value)
52
+
53
+
54
+ class TaskDefinition[InputT: BaseModel, OutputT]:
55
+ """An asyncio task type: name, models, policy and the handler."""
56
+
57
+ executor = Executor.ASYNCIO
58
+
59
+ def __init__(
60
+ self,
61
+ name: str,
62
+ *,
63
+ input_model: type[InputT],
64
+ output_model: type[BaseModel] | None,
65
+ policy: Policy,
66
+ handler: Handler[InputT, OutputT] | None,
67
+ ) -> None:
68
+ store.check_name(name)
69
+ self.name = name
70
+ self.input_model = input_model
71
+ self.output_model = output_model
72
+ self.policy = policy
73
+ self.handler = handler
74
+
75
+ def __repr__(self) -> str:
76
+ return f"<{type(self).__name__} {self.name!r}>"
77
+
78
+ @property
79
+ def spec(self) -> TaskTypeSpec:
80
+ return TaskTypeSpec(
81
+ name=self.name,
82
+ executor=self.executor,
83
+ input_schema=self.input_model.model_json_schema(mode="validation"),
84
+ output_schema=(
85
+ None
86
+ if self.output_model is None
87
+ else self.output_model.model_json_schema(mode="serialization")
88
+ ),
89
+ policy=self.policy,
90
+ )
91
+
92
+ def encode_input(self, input: InputT | Mapping[str, Any], cap: int) -> str:
93
+ """Validate against the input model and encode; enforce the payload cap."""
94
+ model = (
95
+ input if isinstance(input, self.input_model) else self.input_model.model_validate(input)
96
+ )
97
+ value = model.model_dump(mode="json")
98
+ if not isinstance(value, dict):
99
+ msg = f"input of {self.name!r} must serialize to a JSON object"
100
+ raise TypeError(msg)
101
+ try:
102
+ return codec.encode_capped(value, cap, "payload")
103
+ except codec.OverCap as exc:
104
+ raise PayloadTooLarge(str(exc)) from exc
105
+
106
+ async def enqueue( # noqa: PLR0913 # public signature fixed by SPEC.md section 3
107
+ self,
108
+ input: InputT | Mapping[str, Any],
109
+ *,
110
+ conn: store.Conn | None = None,
111
+ priority: int = 0,
112
+ run_at: datetime | None = None,
113
+ key: str | None = None,
114
+ concurrency_key: str | None = None,
115
+ ) -> int:
116
+ """Enqueue and return the task id (or the id of the active task with the same key).
117
+
118
+ With `conn` the insert joins the caller's transaction (never committed by Fronta);
119
+ without it the process-global pool is used and the insert is committed here.
120
+ """
121
+ settings = runtime.get_settings()
122
+ new_task = NewTask(
123
+ type=self.name,
124
+ input_json=self.encode_input(input, settings.payload_cap),
125
+ policy=self.policy,
126
+ priority=priority,
127
+ run_at=run_at,
128
+ key=key,
129
+ concurrency_key=concurrency_key,
130
+ )
131
+ deadline = settings.statement_timeout_s
132
+ if conn is not None:
133
+ return await store.enqueue(conn, new_task, deadline_s=deadline)
134
+ pool = await runtime.get_pool()
135
+ async with pool.connection() as own_conn, own_conn.transaction():
136
+ return await store.enqueue(own_conn, new_task, deadline_s=deadline)
137
+
138
+
139
+ class ProcessTaskDefinition[InputT: BaseModel](TaskDefinition[InputT, dict[str, Any]]):
140
+ """A sandboxed executable: input on stdin, result `{exit_code, stdout, stderr, truncated}`."""
141
+
142
+ executor = Executor.PROCESS
143
+
144
+ def __init__(
145
+ self,
146
+ name: str,
147
+ *,
148
+ argv: tuple[str, ...],
149
+ input_model: type[InputT],
150
+ policy: Policy,
151
+ sandbox: Sandbox,
152
+ ) -> None:
153
+ if not argv:
154
+ msg = "argv must not be empty"
155
+ raise ValueError(msg)
156
+ super().__init__(
157
+ name, input_model=input_model, output_model=None, policy=policy, handler=None
158
+ )
159
+ self.argv = argv
160
+ self.sandbox = sandbox
161
+
162
+
163
+ def _policy(
164
+ max_attempts: int,
165
+ attempt_timeout: float | timedelta,
166
+ backoff: Backoff,
167
+ max_concurrency: int | None,
168
+ max_concurrency_per_key: int | None,
169
+ ) -> Policy:
170
+ return Policy(
171
+ max_attempts=max_attempts,
172
+ attempt_timeout_s=_seconds(attempt_timeout),
173
+ backoff=backoff,
174
+ max_concurrency=max_concurrency,
175
+ max_concurrency_per_key=max_concurrency_per_key,
176
+ )
177
+
178
+
179
+ def task[InputT: BaseModel, OutputT]( # noqa: PLR0913 # public signature fixed by SPEC.md
180
+ name: str,
181
+ *,
182
+ input: type[InputT],
183
+ output: type[BaseModel] | None = None,
184
+ max_attempts: int = 3,
185
+ attempt_timeout: float | timedelta = 3600.0,
186
+ backoff: Backoff | None = None,
187
+ max_concurrency: int | None = None,
188
+ max_concurrency_per_key: int | None = None,
189
+ ) -> Callable[[Handler[InputT, OutputT]], TaskDefinition[InputT, OutputT]]:
190
+ """Declare an asyncio task type. Decorates `async def handler(ctx, input) -> output`."""
191
+ policy = _policy(
192
+ max_attempts,
193
+ attempt_timeout,
194
+ backoff or Backoff(),
195
+ max_concurrency,
196
+ max_concurrency_per_key,
197
+ )
198
+
199
+ def decorate(handler: Handler[InputT, OutputT]) -> TaskDefinition[InputT, OutputT]:
200
+ return TaskDefinition(
201
+ name, input_model=input, output_model=output, policy=policy, handler=handler
202
+ )
203
+
204
+ return decorate
205
+
206
+
207
+ def process_task[InputT: BaseModel]( # noqa: PLR0913 # public signature fixed by SPEC.md
208
+ name: str,
209
+ argv: tuple[str, ...] | list[str],
210
+ *,
211
+ input: type[InputT],
212
+ sandbox: Sandbox | None = None,
213
+ max_attempts: int = 3,
214
+ attempt_timeout: float | timedelta = 3600.0,
215
+ backoff: Backoff | None = None,
216
+ max_concurrency: int | None = None,
217
+ max_concurrency_per_key: int | None = None,
218
+ ) -> ProcessTaskDefinition[InputT]:
219
+ """Declare a sandboxed process task type. `argv[0]` is resolved inside the sandbox."""
220
+ policy = _policy(
221
+ max_attempts,
222
+ attempt_timeout,
223
+ backoff or Backoff(),
224
+ max_concurrency,
225
+ max_concurrency_per_key,
226
+ )
227
+ return ProcessTaskDefinition(
228
+ name, argv=tuple(argv), input_model=input, policy=policy, sandbox=sandbox or Sandbox()
229
+ )
fronta/errors.py ADDED
@@ -0,0 +1,53 @@
1
+ """Domain exceptions.
2
+
3
+ They live in one bottom-layer module because they form the public API surface (`fronta.X`) and are
4
+ raised and handled across layers (codec -> executors -> worker/server).
5
+ """
6
+
7
+
8
+ class FrontaError(Exception):
9
+ """Base class of every Fronta error."""
10
+
11
+
12
+ class ConfigurationError(FrontaError):
13
+ """Invalid settings or definition parameters."""
14
+
15
+
16
+ class PayloadTooLarge(FrontaError):
17
+ """Enqueue input exceeds the payload cap (UTF-8 bytes of the JSON encoding)."""
18
+
19
+
20
+ class ProgressTooLarge(FrontaError):
21
+ """`ctx.progress()` value exceeds the progress cap."""
22
+
23
+
24
+ class InputValidationError(FrontaError):
25
+ """Stored input does not match the input model at claim time. Fails the task without retry."""
26
+
27
+
28
+ class ResultSerializationError(FrontaError):
29
+ """Result is not JSON, not finite, over the cap, or violates the output model. No retry."""
30
+
31
+
32
+ class NonRetryableError(FrontaError):
33
+ """Raised by a handler to fail the task without retry."""
34
+
35
+
36
+ class UnknownTaskType(FrontaError):
37
+ """The task type has not been published by any worker."""
38
+
39
+
40
+ class TaskNotFound(FrontaError):
41
+ """No task with this id."""
42
+
43
+
44
+ class NotCancellable(FrontaError):
45
+ """The task is already terminal."""
46
+
47
+
48
+ class InvalidInput(FrontaError):
49
+ """Server-side input does not match the published JSON schema."""
50
+
51
+
52
+ class SandboxError(FrontaError):
53
+ """The sandbox cannot be set up on this host (probe failed) or a spawn failed."""