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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Deferred-task exception hierarchy** (`jvspatial/exceptions.py`) —
`DeferredTaskError` → `TaskDispatchError` → `TaskSchedulerNotConfiguredError`,
replacing the bare `RuntimeError`s raised by strict dispatch. A strict caller
can now tell "retrying may succeed" (`TaskDispatchError`) from "this
deployment will never dispatch" (`TaskSchedulerNotConfiguredError`).
`DeferredTaskError` also derives from `RuntimeError`, so handlers written
against the previous behavior keep working.

### Fixed

- **Strict deferred scheduling only caught the no-op-scheduler case**
(`jvspatial/serverless/`). `dispatch_deferred_task(..., strict=True)` raised
when serverless mode resolved a logging no-op, but every *provider* failure
still logged and returned a synthetic reference — so a caller with its own
failure handling (retry, error signalled upstream, dedup claim released) was
told the task was queued when it had been dropped. `strict` now raises on:
an unset `AWS_LAMBDA_FUNCTION_NAME`; a Lambda `invoke` that raises **or**
answers a non-2xx `StatusCode` / carries a `FunctionError` (an async invoke
returns 202 on acceptance, so boto3 not raising was never proof of
dispatch); an unconfigured SQS client or queue; a failed SQS `send_message`;
a `NoopOrSyncScheduler` with no executor — the scheduler every
*non*-serverless caller gets, which silently dropped strict tasks; and an
EventBridge scheduling failure for a task deferred beyond Lambda's 900s
timeout, where the fallback immediate invoke cannot honor `run_at`.
- **Non-strict dispatch failed differently per transport**
(`jvspatial/serverless/tasks/aws_sqs.py`). SQS `send_message` errors
propagated while the Lambda transport swallowed them, so identical
application code had opposite failure semantics depending on
`JVSPATIAL_AWS_DEFERRED_TRANSPORT`. `strict` is now the single switch on
every transport: `False` is fire-and-forget, `True` raises.
- **The one-time no-op diagnostic never fired for strict callers**
(`jvspatial/serverless/factory.py`). The strict raise preceded
`_note_noop_in_serverless`, so a deployment whose callers are all strict
never got the startup error explaining *why* nothing dispatches. The
diagnostic is now emitted first.

### Changed

- **`TaskScheduler.schedule` takes a `strict` argument**
(`jvspatial/serverless/tasks/base.py`). `TaskScheduler` is a public/stable
extension point and `config.task_scheduler` is duck-typed, so
`dispatch_deferred_task` introspects `schedule()` and omits `strict` for
third-party implementations that predate it — those keep serving non-strict
dispatches unchanged. A `strict=True` dispatch through such a scheduler
raises `TaskSchedulerNotConfiguredError` (it cannot honor the guarantee)
rather than `TypeError`.

## [0.0.11] - 2026-07-02

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,13 @@ Detection results are memoized via `lru_cache`; tests call `reset_serverless_mod

Register handlers with `@deferred_invoke_handler("task.name")`. Handlers **must be idempotent**; the framework provides no exactly-once guarantee.

**Dispatch failure contract** (`strict`, default `False`):

- **`strict=False`** — fire-and-forget on every transport. A dispatch that cannot be handed to the provider is logged and a synthetic reference returned.
- **`strict=True`** — every such path raises instead. `TaskSchedulerNotConfiguredError` when the deployment can never dispatch (no-op scheduler in serverless mode, `AWS_LAMBDA_FUNCTION_NAME` unset, SQS unconfigured, `NoopOrSyncScheduler` without an executor, or a scheduler predating the `strict` parameter); `TaskDispatchError` when the provider was reached and failed (a raised or non-2xx / `FunctionError` Lambda `invoke`, a raised SQS `send_message`, or an EventBridge failure for a task deferred beyond Lambda's 900s timeout). Both derive from `DeferredTaskError` → `RuntimeError`.

`TaskScheduler` is a public/stable extension point and `config.task_scheduler` is duck-typed, so `dispatch_deferred_task` introspects `schedule()` and omits `strict` for implementations that predate it. Such a scheduler still serves non-strict dispatches; a `strict=True` dispatch through it raises `TaskSchedulerNotConfiguredError` rather than a `TypeError`.

### 11.4 Lambda Web Adapter

When LWA is detected, `Server` applies best-effort defaults for `AWS_LWA_PASS_THROUGH_PATH` and `AWS_LWA_INVOKE_MODE`. The LWA extension reads these *before* Python starts, so IaC should still set them explicitly for reliability.
Expand Down
9 changes: 8 additions & 1 deletion docs/md/serverless-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,14 @@ Serverless runtimes cannot rely on `asyncio.create_task` for work that must cont
- **Not serverless**: the default scheduler is a no-op unless you pass a sync executor to `NoopOrSyncScheduler` or use `override=`.
- **AWS**: By default, if `AWS_LAMBDA_FUNCTION_NAME` is set, tasks are sent with **Lambda async invoke** (`InvocationType=Event`). **EventBridge Scheduler** one-shot schedules apply when `run_at` is set, EventBridge is enabled, and role/ARN requirements are satisfied (see below).
- **SQS**: Set `JVSPATIAL_AWS_DEFERRED_TRANSPORT=sqs` and `JVSPATIAL_AWS_SQS_QUEUE_URL`; you must run a worker that consumes messages. Messages use a nested `payload` object. Before calling `dispatch_deferred_invoke`, flatten with **`normalize_deferred_envelope`** (exported from `jvspatial`) so the body matches the Lambda/LWA shape.
- **Strict dispatch**: `create_task("…", {}, strict=True)` (or `dispatch_deferred_task(..., strict=True)`) raises `RuntimeError` if serverless mode is on but the resolved scheduler is a logging no-op. Otherwise the first no-op schedule in serverless mode emits a one-time **error** log.
- **Strict dispatch**: `create_task("…", {}, strict=True)` (or `dispatch_deferred_task(..., strict=True)`) raises whenever the task could not be handed to the provider, instead of returning a synthetic reference. Pass it when you have your own failure handling — retrying, signalling an error upstream, releasing a dedup claim — and a dropped task would be data loss. Without it, dispatch stays fire-and-forget on every transport and the first no-op schedule in serverless mode emits a one-time **error** log.

| Exception | When |
|---|---|
| `TaskSchedulerNotConfiguredError` | The deployment can never dispatch: a logging no-op resolved in serverless mode, `AWS_LAMBDA_FUNCTION_NAME` unset, SQS client/queue unconfigured, a `NoopOrSyncScheduler` with no executor, or a scheduler whose `schedule()` predates the `strict` parameter. Retrying will not help. |
| `TaskDispatchError` | The provider was reached and the call failed: a Lambda `invoke` that raised or answered non-2xx / `FunctionError`, an SQS `send_message` that raised, or an EventBridge schedule that failed for a task deferred past Lambda's 900s timeout (an immediate invoke cannot honor `run_at`). |

Both derive from `DeferredTaskError`, which derives from `RuntimeError` — handlers written against the earlier bare-`RuntimeError` behavior keep working. Third-party `TaskScheduler` implementations written before `strict` existed are detected by signature and called without it, so non-strict dispatch is unaffected.
- **Provider override**: `JVSPATIAL_DEFERRED_TASK_PROVIDER` (`aws`, `azure`, `gcp`, `vercel`, `auto`) or `Config.deferred_task_provider` / `ServerConfig.deferred_task_provider`.
- **Detection**: `detect_serverless_provider()` complements `is_serverless_mode()` for choosing a backend.

Expand Down
45 changes: 45 additions & 0 deletions jvspatial/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,47 @@ def __init__(self, config_key: str, details: Optional[Dict[str, Any]] = None):
self.config_key = config_key


# =============================================================================
# SERVERLESS / DEFERRED TASK EXCEPTIONS
# =============================================================================


class DeferredTaskError(JVSpatialError, RuntimeError):
"""Base exception for deferred task dispatch.

Also inherits :class:`RuntimeError` so existing ``except RuntimeError``
handlers around ``dispatch_deferred_task`` keep working — the strict
no-op guard raised a bare ``RuntimeError`` before this hierarchy existed.
"""


class TaskDispatchError(DeferredTaskError):
"""Raised when a task was handed to the provider and the provider failed.

Distinguishable from :class:`TaskSchedulerNotConfiguredError` so a strict
caller can tell "retry may succeed" from "this deployment will never
dispatch". Only raised when the caller passed ``strict=True``; the
default fire-and-forget contract logs and returns a synthetic reference.
"""

def __init__(
self, task_type: str, reason: str, details: Optional[Dict[str, Any]] = None
):
message = f"Deferred task {task_type!r} was not dispatched: {reason}"
super().__init__(message, details)
self.task_type = task_type
self.reason = reason


class TaskSchedulerNotConfiguredError(TaskDispatchError):
"""Raised when the resolved scheduler cannot dispatch at all.

A missing ``AWS_LAMBDA_FUNCTION_NAME``, an unconfigured SQS client or
queue URL, or a logging no-op scheduler resolved while serverless mode is
on. Retrying will not help; the deployment needs configuration.
"""


# =============================================================================
# EXPORTS
# =============================================================================
Expand Down Expand Up @@ -472,4 +513,8 @@ def __init__(self, config_key: str, details: Optional[Dict[str, Any]] = None):
# Configuration exceptions
"InvalidConfigurationError",
"MissingConfigurationError",
# Serverless / deferred task exceptions
"DeferredTaskError",
"TaskDispatchError",
"TaskSchedulerNotConfiguredError",
]
87 changes: 72 additions & 15 deletions jvspatial/serverless/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

from __future__ import annotations

import inspect
import logging
from typing import Any, Optional
from functools import lru_cache
from typing import Any, Dict, Optional

from jvspatial.env import env
from jvspatial.exceptions import TaskSchedulerNotConfiguredError
from jvspatial.runtime.serverless import detect_serverless_provider, is_serverless_mode

from .tasks.aws_lambda import AwsLambdaDeferredTaskScheduler
Expand Down Expand Up @@ -111,6 +114,31 @@ def get_task_scheduler(
)


@lru_cache(maxsize=None)
def _scheduler_accepts_strict(sched_type: type) -> bool:
"""Whether ``sched_type.schedule`` takes a ``strict`` argument.

``TaskScheduler`` is public/stable (``docs/md/stability.md``) and
``config.task_scheduler`` is duck-typed, so third-party schedulers written
against the pre-``strict`` signature are in the wild. Passing ``strict=``
unconditionally would break every one of them on every dispatch, including
non-strict ones. Cached per class — this is on the dispatch path.

Unintrospectable callables (C extensions, some mocks) are assumed modern:
the ABC declares the parameter, so that is the better default.
"""
schedule = getattr(sched_type, "schedule", None)
if schedule is None:
return True
try:
params = inspect.signature(schedule).parameters
except (TypeError, ValueError): # pragma: no cover - exotic callables
return True
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
return True
return "strict" in params


def _note_noop_in_serverless(sched: TaskScheduler, config: Optional[Any]) -> None:
global _NOOP_DEFERRED_LOGGED
if not isinstance(sched, LoggingNoopTaskScheduler):
Expand Down Expand Up @@ -140,25 +168,54 @@ def dispatch_deferred_task(
"""Schedule a JSON-serializable deferred task; thin wrapper over :func:`get_task_scheduler`.

Args:
strict: If True and serverless mode is on but the resolved scheduler is
:class:`LoggingNoopTaskScheduler`, raise ``RuntimeError`` instead of
returning a synthetic reference.
strict: If True, scheduling failures raise instead of returning a
synthetic reference: the noop-scheduler-in-serverless case (as
before), AND provider dispatch failures — a missing
``AWS_LAMBDA_FUNCTION_NAME``, a failed or rejected Lambda
``invoke``, an unconfigured SQS client, a failed
``send_message``, a non-serverless ``NoopOrSyncScheduler`` with no
executor. Callers passing ``strict=True`` have their own failure
handling; a synthetic reference for an undispatched task turns
that handling into silent data loss.

Raises:
TaskSchedulerNotConfiguredError: ``strict`` and the deployment cannot
dispatch at all — including when the resolved scheduler predates
the ``strict`` parameter and so cannot honor the guarantee.
TaskDispatchError: ``strict`` and the provider rejected or failed the
call. Both derive from ``RuntimeError``, so handlers written
against the previous bare-``RuntimeError`` raise still work.
"""
sched = get_task_scheduler(config, override=override)
# Emit the once-per-process diagnostic before the strict raise, so a
# deployment whose callers are all strict still gets the startup error
# telling it *why* nothing dispatches.
_note_noop_in_serverless(sched, config)
if (
strict
and is_serverless_mode(config)
and isinstance(sched, LoggingNoopTaskScheduler)
):
raise RuntimeError(
"Deferred task scheduler is a no-op in serverless mode; configure an AWS "
"transport (Lambda/SQS), or inject config.task_scheduler."
raise TaskSchedulerNotConfiguredError(
task_type,
"the resolved scheduler is a no-op in serverless mode; configure an "
"AWS transport (Lambda/SQS), or inject config.task_scheduler",
)
_note_noop_in_serverless(sched, config)
return sched.schedule(
task_type,
payload,
delay_seconds=delay_seconds,
retry_config=retry_config,
run_at=run_at,
)

kwargs: Dict[str, Any] = {
"delay_seconds": delay_seconds,
"retry_config": retry_config,
"run_at": run_at,
}
if _scheduler_accepts_strict(type(sched)):
kwargs["strict"] = strict
elif strict:
# A scheduler predating the ``strict`` parameter cannot honor the
# guarantee the caller is asking for. Say so, rather than passing an
# argument it will reject with TypeError.
raise TaskSchedulerNotConfiguredError(
task_type,
f"{type(sched).__name__}.schedule() does not accept 'strict'; it "
"predates strict scheduling and cannot guarantee dispatch",
)
return sched.schedule(task_type, payload, **kwargs)
Loading