render-lab-triggers 0.1.0__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
9
+ .env
10
+ .env.*
11
+ !.env.example
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Render Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.5
2
+ Name: render-lab-triggers
3
+ Version: 0.1.0
4
+ Summary: HTTP, webhook, and cron dispatch for Render workflows
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: render==1.0.1
9
+ Requires-Dist: uvicorn<1,>=0.35
10
+ Description-Content-Type: text/markdown
11
+
12
+ # render-lab-triggers
13
+
14
+ Registration-free HTTP, webhook, and cron dispatch to a separate Render Workflow.
15
+
16
+ ```python
17
+ from render_lab_triggers import create_dispatch_server
18
+
19
+ app = create_dispatch_server()
20
+ # uvicorn main:app --host 0.0.0.0 --port 3000
21
+ ```
22
+
23
+ `GET /healthz` returns `ok`. `POST /tasks/{namespace.task}` requires
24
+ `Authorization: Bearer <DISPATCH_TOKEN>` and accepts JSON positional arguments
25
+ (an array), a single JSON value, or an empty body (`[{}]`). It returns HTTP 202
26
+ with `runId`. Add `?wait=1` for a bounded wait: HTTP 200 with status/results on
27
+ completion or HTTP 202 when the wait expires. The default wait is 25 seconds.
28
+
29
+ `create_dispatch_server` accepts `workflow_slug`, `token`, `dispatcher`,
30
+ `webhooks`, `wait_timeout_ms`, and `max_body_bytes`. Bodies are capped at 1 MiB
31
+ by counting streamed bytes, regardless of Content-Length. Oversized bodies
32
+ return 413 before signature verification. The ASGI server owns connection cleanup.
33
+
34
+ Vendor adapters live in `render_lab_tasks_<vendor>.webhooks`; mount them in the
35
+ `webhooks` mapping at `/webhooks/{name}`. Their `verify` function sees the exact
36
+ raw UTF-8 body and lowercase headers before JSON parsing. `map` returns a
37
+ `{"task": ..., "args": [...]}` dispatch or `None` to acknowledge and ignore.
38
+ As in the pinned TS server, webhook bodies must be JSON. Twilio form-encoded
39
+ signatures can be verified with its standalone adapter, but form dispatch needs
40
+ a custom endpoint that decodes the form after verification.
41
+
42
+ ```python
43
+ from render_lab_triggers import run_cron
44
+
45
+ # In an async cron entry point:
46
+ # result = await run_cron(task="report.daily", args=[{}])
47
+ ```
48
+
49
+ `run_cron` defaults to asynchronous dispatch. `wait=True` waits at most five
50
+ minutes; `wait_timeout_ms` overrides it. `render_dispatcher(slug=...)` starts via
51
+ the pinned Render SDK and consumes its public SSE event iterator. It closes
52
+ streams on completion, timeout, and cancellation, without adding retries.
53
+ `client=` injects a RenderAsync-compatible client; the caller owns that client.
54
+ `serve_dispatch_server(port=..., **options)` runs Uvicorn until shutdown.
55
+
56
+ ## Environment
57
+
58
+ - `WORKFLOW_SLUG`: target Workflow slug, unless provided explicitly.
59
+ - `DISPATCH_TOKEN`: bearer token for `/tasks/*`; unset rejects all such requests.
60
+ - `RENDER_API_KEY`: Render SDK credential, read when dispatching.
61
+ - `CRON_TASK`: default cron task name.
62
+ - `CRON_INPUT`: JSON cron input; an array is positional arguments.
63
+ - `PORT`: serving port, default 3000.
64
+
65
+ Imports never read credentials or register tasks. Tests inject dispatchers and
66
+ SDK event streams; live cross-service dispatch remains in the testing backlog.
67
+
68
+ ## Installation
69
+
70
+ ```sh
71
+ pip install render-lab-triggers==0.1.0
72
+ ```
@@ -0,0 +1,61 @@
1
+ # render-lab-triggers
2
+
3
+ Registration-free HTTP, webhook, and cron dispatch to a separate Render Workflow.
4
+
5
+ ```python
6
+ from render_lab_triggers import create_dispatch_server
7
+
8
+ app = create_dispatch_server()
9
+ # uvicorn main:app --host 0.0.0.0 --port 3000
10
+ ```
11
+
12
+ `GET /healthz` returns `ok`. `POST /tasks/{namespace.task}` requires
13
+ `Authorization: Bearer <DISPATCH_TOKEN>` and accepts JSON positional arguments
14
+ (an array), a single JSON value, or an empty body (`[{}]`). It returns HTTP 202
15
+ with `runId`. Add `?wait=1` for a bounded wait: HTTP 200 with status/results on
16
+ completion or HTTP 202 when the wait expires. The default wait is 25 seconds.
17
+
18
+ `create_dispatch_server` accepts `workflow_slug`, `token`, `dispatcher`,
19
+ `webhooks`, `wait_timeout_ms`, and `max_body_bytes`. Bodies are capped at 1 MiB
20
+ by counting streamed bytes, regardless of Content-Length. Oversized bodies
21
+ return 413 before signature verification. The ASGI server owns connection cleanup.
22
+
23
+ Vendor adapters live in `render_lab_tasks_<vendor>.webhooks`; mount them in the
24
+ `webhooks` mapping at `/webhooks/{name}`. Their `verify` function sees the exact
25
+ raw UTF-8 body and lowercase headers before JSON parsing. `map` returns a
26
+ `{"task": ..., "args": [...]}` dispatch or `None` to acknowledge and ignore.
27
+ As in the pinned TS server, webhook bodies must be JSON. Twilio form-encoded
28
+ signatures can be verified with its standalone adapter, but form dispatch needs
29
+ a custom endpoint that decodes the form after verification.
30
+
31
+ ```python
32
+ from render_lab_triggers import run_cron
33
+
34
+ # In an async cron entry point:
35
+ # result = await run_cron(task="report.daily", args=[{}])
36
+ ```
37
+
38
+ `run_cron` defaults to asynchronous dispatch. `wait=True` waits at most five
39
+ minutes; `wait_timeout_ms` overrides it. `render_dispatcher(slug=...)` starts via
40
+ the pinned Render SDK and consumes its public SSE event iterator. It closes
41
+ streams on completion, timeout, and cancellation, without adding retries.
42
+ `client=` injects a RenderAsync-compatible client; the caller owns that client.
43
+ `serve_dispatch_server(port=..., **options)` runs Uvicorn until shutdown.
44
+
45
+ ## Environment
46
+
47
+ - `WORKFLOW_SLUG`: target Workflow slug, unless provided explicitly.
48
+ - `DISPATCH_TOKEN`: bearer token for `/tasks/*`; unset rejects all such requests.
49
+ - `RENDER_API_KEY`: Render SDK credential, read when dispatching.
50
+ - `CRON_TASK`: default cron task name.
51
+ - `CRON_INPUT`: JSON cron input; an array is positional arguments.
52
+ - `PORT`: serving port, default 3000.
53
+
54
+ Imports never read credentials or register tasks. Tests inject dispatchers and
55
+ SDK event streams; live cross-service dispatch remains in the testing backlog.
56
+
57
+ ## Installation
58
+
59
+ ```sh
60
+ pip install render-lab-triggers==0.1.0
61
+ ```
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "render-lab-triggers"
7
+ version = "0.1.0"
8
+ description = "HTTP, webhook, and cron dispatch for Render workflows"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = ["render==1.0.1", "uvicorn>=0.35,<1"]
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["src/render_lab_triggers"]
@@ -0,0 +1,6 @@
1
+ """Registration-free HTTP, webhook, and cron dispatch."""
2
+
3
+ from .cron import run_cron as run_cron
4
+ from .dispatch import render_dispatcher as render_dispatcher
5
+ from .server import create_dispatch_server as create_dispatch_server
6
+ from .server import serve_dispatch_server as serve_dispatch_server
@@ -0,0 +1,50 @@
1
+ """Dispatch one run from a cron job."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import cast
8
+
9
+ from .dispatch import render_dispatcher
10
+ from .types import CronResult, Json, WorkflowDispatcher
11
+
12
+
13
+ def parse_args(text: str | None) -> list[Json]:
14
+ if not text:
15
+ return [{}]
16
+
17
+ def invalid(value: str) -> None:
18
+ raise ValueError(f"Invalid JSON constant: {value}")
19
+
20
+ value = cast(Json, json.loads(text, parse_constant=invalid))
21
+ # Reject overflow to infinity as well as named nonfinite constants.
22
+ json.dumps(value, allow_nan=False)
23
+ return value if isinstance(value, list) else [value]
24
+
25
+
26
+ async def run_cron(
27
+ *,
28
+ workflow_slug: str | None = None,
29
+ task: str | None = None,
30
+ args: list[Json] | None = None,
31
+ dispatcher: WorkflowDispatcher | None = None,
32
+ wait: bool = False,
33
+ wait_timeout_ms: float = 300_000,
34
+ ) -> CronResult:
35
+ slug = workflow_slug if workflow_slug is not None else os.getenv("WORKFLOW_SLUG")
36
+ if not slug:
37
+ raise ValueError("run_cron: set WORKFLOW_SLUG or pass workflow_slug.")
38
+ target = task if task is not None else os.getenv("CRON_TASK")
39
+ if not target:
40
+ raise ValueError("run_cron: set CRON_TASK or pass task.")
41
+ inputs = args if args is not None else parse_args(os.getenv("CRON_INPUT"))
42
+ dispatch = dispatcher if dispatcher is not None else render_dispatcher(slug=slug)
43
+ if wait:
44
+ result = await dispatch.run(target, inputs, wait_timeout_ms)
45
+ out: CronResult = {"runId": result["runId"], "status": result["status"]}
46
+ if "results" in result:
47
+ out["results"] = result["results"]
48
+ return out
49
+ started = await dispatch.start(target, inputs)
50
+ return {"runId": started["runId"], "status": "running"}
@@ -0,0 +1,71 @@
1
+ """Bounded cross-service dispatch using the Render SDK's public SSE iterator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from .types import Json, RunOutcome, StartedRun
11
+
12
+ if TYPE_CHECKING:
13
+ from render import RenderAsync
14
+ from render.client.workflows import WorkflowsService
15
+
16
+
17
+ class RenderDispatcher:
18
+ def __init__(self, slug: str, client: RenderAsync | None = None) -> None:
19
+ self.slug = slug
20
+ self._client = client
21
+
22
+ @asynccontextmanager
23
+ async def _service(self) -> AsyncIterator[WorkflowsService]:
24
+ if self._client is not None:
25
+ yield self._client.workflows
26
+ return
27
+ from render import RenderAsync
28
+
29
+ client = RenderAsync()
30
+ # The generated client owns its HTTP pool; close it after this dispatch.
31
+ async with client.client.internal:
32
+ yield client.workflows
33
+
34
+ async def start(self, task: str, args: list[Json]) -> StartedRun:
35
+ async with self._service() as service:
36
+ started = await service.start_task(f"{self.slug}/{task}", args)
37
+ return {"runId": started.id}
38
+
39
+ async def run(self, task: str, args: list[Json], timeout_ms: float) -> RunOutcome:
40
+ async with self._service() as service:
41
+ started = await service.start_task(f"{self.slug}/{task}", args)
42
+ stream = service.task_run_events([started.id])
43
+ # aclosing's protocol is stronger than the SDK iterator annotation;
44
+ # close the public async generator when it exposes aclose.
45
+ try:
46
+ async with asyncio.timeout(max(0, timeout_ms) / 1000) as deadline:
47
+ async for details in stream:
48
+ if details.id != started.id:
49
+ continue
50
+ status = str(details.status)
51
+ if status not in {"succeeded", "completed", "failed", "canceled"}:
52
+ continue
53
+ return {
54
+ "runId": started.id,
55
+ "status": status,
56
+ "results": cast(Json, details.results),
57
+ "timedOut": False,
58
+ }
59
+ raise RuntimeError("Workflow event stream ended before the run finished")
60
+ except TimeoutError:
61
+ if not deadline.expired():
62
+ raise
63
+ return {"runId": started.id, "status": "running", "timedOut": True}
64
+ finally:
65
+ close = getattr(stream, "aclose", None)
66
+ if close is not None:
67
+ await close()
68
+
69
+
70
+ def render_dispatcher(*, slug: str, client: RenderAsync | None = None) -> RenderDispatcher:
71
+ return RenderDispatcher(slug, client)
@@ -0,0 +1,185 @@
1
+ """Small ASGI dispatch application with bounded request bodies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import json
7
+ import os
8
+ from collections.abc import Awaitable, Callable, Mapping
9
+ from typing import Any
10
+ from urllib.parse import parse_qs
11
+
12
+ from .cron import parse_args
13
+ from .dispatch import render_dispatcher
14
+ from .types import Json, WebhookAdapter, WorkflowDispatcher
15
+
16
+ type Message = dict[str, Any]
17
+ type Receive = Callable[[], Awaitable[Message]]
18
+ type Send = Callable[[Message], Awaitable[None]]
19
+
20
+
21
+ async def _body(receive: Receive, maximum: int) -> str | None:
22
+ chunks: list[bytes] = []
23
+ size = 0
24
+ while True:
25
+ message = await receive()
26
+ if message["type"] == "http.disconnect":
27
+ raise ConnectionError("Client disconnected")
28
+ chunk: bytes = message.get("body", b"")
29
+ size += len(chunk)
30
+ if size > maximum:
31
+ return None
32
+ chunks.append(chunk)
33
+ if not message.get("more_body", False):
34
+ return b"".join(chunks).decode("utf-8", errors="replace")
35
+
36
+
37
+ async def _respond(send: Send, status: int, body: Json = None, *, text: bool = False) -> None:
38
+ data = (
39
+ b""
40
+ if status == 204
41
+ else (
42
+ str(body).encode()
43
+ if text
44
+ else json.dumps(body, allow_nan=False, separators=(",", ":")).encode()
45
+ )
46
+ )
47
+ content_type = b"text/plain; charset=UTF-8" if text else b"application/json"
48
+ await send(
49
+ {
50
+ "type": "http.response.start",
51
+ "status": status,
52
+ "headers": [
53
+ (b"content-type", content_type),
54
+ (b"content-length", str(len(data)).encode()),
55
+ ],
56
+ }
57
+ )
58
+ await send({"type": "http.response.body", "body": data})
59
+
60
+
61
+ class DispatchServer:
62
+ def __init__(
63
+ self,
64
+ *,
65
+ workflow_slug: str | None = None,
66
+ token: str | None = None,
67
+ webhooks: Mapping[str, WebhookAdapter] | None = None,
68
+ dispatcher: WorkflowDispatcher | None = None,
69
+ wait_timeout_ms: float = 25_000,
70
+ max_body_bytes: int = 1_048_576,
71
+ ) -> None:
72
+ if max_body_bytes < 0:
73
+ raise ValueError("max_body_bytes must be nonnegative")
74
+ self.slug = workflow_slug if workflow_slug is not None else os.getenv("WORKFLOW_SLUG")
75
+ self.token = token if token is not None else os.getenv("DISPATCH_TOKEN")
76
+ self.webhooks = dict(webhooks or {})
77
+ self.dispatcher = (
78
+ dispatcher if dispatcher is not None else render_dispatcher(slug=self.slug or "")
79
+ )
80
+ self.wait_timeout_ms = wait_timeout_ms
81
+ self.max_body_bytes = max_body_bytes
82
+
83
+ async def __call__(self, scope: Message, receive: Receive, send: Send) -> None:
84
+ if scope["type"] == "lifespan":
85
+ while True:
86
+ message = await receive()
87
+ if message["type"] == "lifespan.startup":
88
+ await send({"type": "lifespan.startup.complete"})
89
+ elif message["type"] == "lifespan.shutdown":
90
+ await send({"type": "lifespan.shutdown.complete"})
91
+ return
92
+ if scope["type"] != "http":
93
+ raise ValueError("Only HTTP and lifespan ASGI scopes are supported")
94
+ path = scope["path"]
95
+ if path == "/healthz" and scope["method"] == "GET":
96
+ await _respond(send, 200, "ok", text=True)
97
+ return
98
+ parts = path.split("/")
99
+ generic = len(parts) == 3 and parts[1] == "tasks" and bool(parts[2])
100
+ adapter = (
101
+ self.webhooks.get(parts[2]) if len(parts) == 3 and parts[1] == "webhooks" else None
102
+ )
103
+ if scope["method"] != "POST" or not (generic or adapter):
104
+ await _respond(send, 404, {"error": "not found"})
105
+ return
106
+ headers = {
107
+ k.decode("latin1").lower(): v.decode("latin1") for k, v in scope.get("headers", [])
108
+ }
109
+ if generic:
110
+ header = headers.get("authorization", "")
111
+ if (
112
+ not self.token
113
+ or not header.startswith("Bearer ")
114
+ or not hmac.compare_digest(header[7:].encode(), self.token.encode())
115
+ ):
116
+ await _respond(send, 401, {"error": "unauthorized"})
117
+ return
118
+ if not self.slug:
119
+ await _respond(send, 500, {"error": "WORKFLOW_SLUG not configured"})
120
+ return
121
+ raw = await _body(receive, self.max_body_bytes)
122
+ if raw is None:
123
+ await _respond(send, 413, {"error": "payload too large"})
124
+ return
125
+ if adapter is not None and not adapter.verify({"headers": headers, "rawBody": raw}):
126
+ await _respond(send, 401, {"error": "invalid signature"})
127
+ return
128
+ try:
129
+ args = parse_args(raw)
130
+ body = json.loads(raw) if raw else {}
131
+ except (ValueError, TypeError):
132
+ await _respond(send, 400, {"error": "invalid JSON body"})
133
+ return
134
+ task = parts[2]
135
+ if adapter is not None:
136
+ mapped = adapter.map({"headers": headers, "body": body})
137
+ if mapped is None:
138
+ await _respond(send, 204)
139
+ return
140
+ task, args = mapped["task"], mapped["args"]
141
+ try:
142
+ wait = parse_qs(scope.get("query_string", b"").decode()).get("wait", [""])[0]
143
+ if generic and wait in {"1", "true"}:
144
+ result = await self.dispatcher.run(task, args, self.wait_timeout_ms)
145
+ output: dict[str, Json] = {"runId": result["runId"], "status": result["status"]}
146
+ if result["timedOut"]:
147
+ output["status"] = "running"
148
+ elif "results" in result:
149
+ output["results"] = result["results"]
150
+ await _respond(send, 202 if result["timedOut"] else 200, output)
151
+ else:
152
+ started = await self.dispatcher.start(task, args)
153
+ await _respond(send, 202, {"runId": started["runId"]})
154
+ except Exception as error:
155
+ await _respond(send, 502, {"error": "dispatch failed", "detail": str(error)})
156
+
157
+
158
+ def create_dispatch_server(
159
+ *,
160
+ workflow_slug: str | None = None,
161
+ token: str | None = None,
162
+ webhooks: Mapping[str, WebhookAdapter] | None = None,
163
+ dispatcher: WorkflowDispatcher | None = None,
164
+ wait_timeout_ms: float = 25_000,
165
+ max_body_bytes: int = 1_048_576,
166
+ ) -> DispatchServer:
167
+ return DispatchServer(
168
+ workflow_slug=workflow_slug,
169
+ token=token,
170
+ webhooks=webhooks,
171
+ dispatcher=dispatcher,
172
+ wait_timeout_ms=wait_timeout_ms,
173
+ max_body_bytes=max_body_bytes,
174
+ )
175
+
176
+
177
+ def serve_dispatch_server(*, port: int | None = None, **options: Any) -> None:
178
+ """Serve until shutdown; Python's Uvicorn runner owns the event loop."""
179
+ import uvicorn
180
+
181
+ uvicorn.run(
182
+ create_dispatch_server(**options),
183
+ host="0.0.0.0",
184
+ port=port if port is not None else int(os.getenv("PORT", "3000")),
185
+ )
@@ -0,0 +1,57 @@
1
+ """Registration-free trigger contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import NotRequired, Protocol, TypedDict
8
+
9
+ type Json = None | bool | int | float | str | list[Json] | dict[str, Json]
10
+
11
+
12
+ class StartedRun(TypedDict):
13
+ runId: str
14
+
15
+
16
+ class RunOutcome(StartedRun):
17
+ status: str
18
+ results: NotRequired[Json]
19
+ timedOut: bool
20
+
21
+
22
+ class WorkflowDispatcher(Protocol):
23
+ async def start(self, task: str, args: list[Json]) -> StartedRun: ...
24
+ async def run(self, task: str, args: list[Json], timeout_ms: float) -> RunOutcome: ...
25
+
26
+
27
+ class WebhookRequest(TypedDict):
28
+ headers: dict[str, str]
29
+ rawBody: str
30
+
31
+
32
+ class WebhookContext(TypedDict):
33
+ headers: dict[str, str]
34
+ body: Json
35
+
36
+
37
+ class WebhookDispatch(TypedDict):
38
+ task: str
39
+ args: list[Json]
40
+
41
+
42
+ class WebhookAdapter(Protocol):
43
+ def verify(self, request: WebhookRequest) -> bool: ...
44
+ def map(self, context: WebhookContext) -> WebhookDispatch | None: ...
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Adapter:
49
+ """A pair of pure signature and event mapping functions."""
50
+
51
+ verify: Callable[[WebhookRequest], bool]
52
+ map: Callable[[WebhookContext], WebhookDispatch | None]
53
+
54
+
55
+ class CronResult(StartedRun):
56
+ status: str
57
+ results: NotRequired[Json]
@@ -0,0 +1,195 @@
1
+ import asyncio
2
+ from types import SimpleNamespace
3
+
4
+ import httpx
5
+ import pytest
6
+ from render_lab_triggers import create_dispatch_server, render_dispatcher, run_cron
7
+ from render_lab_triggers.types import Adapter
8
+
9
+
10
+ class Dispatcher:
11
+ def __init__(self, *, timeout=False, failure=False):
12
+ self.calls = []
13
+ self.timeout = timeout
14
+ self.failure = failure
15
+
16
+ async def start(self, task, args):
17
+ self.calls.append((task, args))
18
+ if self.failure:
19
+ raise RuntimeError("vendor unavailable")
20
+ return {"runId": "run-1"}
21
+
22
+ async def run(self, task, args, timeout_ms):
23
+ self.calls.append((task, args, timeout_ms))
24
+ return {"runId": "run-1", "status": "succeeded", "results": [3], "timedOut": self.timeout}
25
+
26
+
27
+ @pytest.mark.parametrize(
28
+ "body,args", [(b"", [{}]), (b'{"a":1}', [{"a": 1}]), (b"[1,2]", [1, 2]), (b"null", [None])]
29
+ )
30
+ async def test_dispatch_args_and_auth(body, args):
31
+ dispatcher = Dispatcher()
32
+ app = create_dispatch_server(workflow_slug="workflow", token="secret", dispatcher=dispatcher)
33
+ async with httpx.AsyncClient(
34
+ transport=httpx.ASGITransport(app), base_url="http://test"
35
+ ) as client:
36
+ assert (await client.get("/healthz")).text == "ok"
37
+ assert (await client.post("/tasks/demo.run", content=body)).status_code == 401
38
+ response = await client.post(
39
+ "/tasks/demo.run", content=body, headers={"Authorization": "Bearer secret"}
40
+ )
41
+ assert response.status_code == 202
42
+ assert response.json() == {"runId": "run-1"}
43
+ assert dispatcher.calls == [("demo.run", args)]
44
+
45
+
46
+ @pytest.mark.parametrize("timed_out,status", [(False, 200), (True, 202)])
47
+ async def test_bounded_wait_response(timed_out, status):
48
+ dispatcher = Dispatcher(timeout=timed_out)
49
+ app = create_dispatch_server(
50
+ workflow_slug="w", token="t", dispatcher=dispatcher, wait_timeout_ms=17
51
+ )
52
+ async with httpx.AsyncClient(
53
+ transport=httpx.ASGITransport(app), base_url="http://test"
54
+ ) as client:
55
+ response = await client.post(
56
+ "/tasks/a?wait=true", json={}, headers={"Authorization": "Bearer t"}
57
+ )
58
+ assert response.status_code == status
59
+ assert response.json() == (
60
+ {"runId": "run-1", "status": "running"}
61
+ if timed_out
62
+ else {"runId": "run-1", "status": "succeeded", "results": [3]}
63
+ )
64
+ assert dispatcher.calls == [("a", [{}], 17)]
65
+
66
+
67
+ async def test_stream_body_limit_precedes_signature_and_ignores_content_length():
68
+ verified = []
69
+ adapter = Adapter(lambda request: verified.append(request) or True, lambda context: None)
70
+ app = create_dispatch_server(workflow_slug="w", webhooks={"vendor": adapter}, max_body_bytes=4)
71
+ chunks = iter([b"123", b"45", b"must not be read"])
72
+ reads, sent = [], []
73
+
74
+ async def receive():
75
+ chunk = next(chunks)
76
+ reads.append(chunk)
77
+ return {"type": "http.request", "body": chunk, "more_body": True}
78
+
79
+ async def send(message):
80
+ sent.append(message)
81
+
82
+ await app(
83
+ {
84
+ "type": "http",
85
+ "method": "POST",
86
+ "path": "/webhooks/vendor",
87
+ "headers": [(b"content-length", b"1")],
88
+ },
89
+ receive,
90
+ send,
91
+ )
92
+ assert sent[0]["status"] == 413
93
+ assert reads == [b"123", b"45"]
94
+ assert not verified
95
+
96
+
97
+ async def test_signature_before_json_then_mapping_and_errors():
98
+ dispatcher = Dispatcher()
99
+ adapter = Adapter(
100
+ lambda request: request["headers"].get("x-signature") == "valid",
101
+ lambda context: (
102
+ {"task": "events.handle", "args": [context["body"]]} if context["body"] else None
103
+ ),
104
+ )
105
+ app = create_dispatch_server(
106
+ workflow_slug="w", token="t", dispatcher=dispatcher, webhooks={"vendor": adapter}
107
+ )
108
+ async with httpx.AsyncClient(
109
+ transport=httpx.ASGITransport(app), base_url="http://test"
110
+ ) as client:
111
+ assert (await client.post("/webhooks/vendor", content="bad")).status_code == 401
112
+ assert (
113
+ await client.post("/webhooks/vendor", content="bad", headers={"X-Signature": "valid"})
114
+ ).status_code == 400
115
+ assert (
116
+ await client.post("/webhooks/vendor", json={}, headers={"X-Signature": "valid"})
117
+ ).status_code == 204
118
+ assert (
119
+ await client.post(
120
+ "/webhooks/vendor", json={"event": 1}, headers={"X-Signature": "valid"}
121
+ )
122
+ ).status_code == 202
123
+ dispatcher.failure = True
124
+ assert (
125
+ await client.post("/tasks/a", headers={"Authorization": "Bearer t"})
126
+ ).status_code == 502
127
+ for body in ["NaN", "1e999"]:
128
+ assert (
129
+ await client.post("/tasks/a", content=body, headers={"Authorization": "Bearer t"})
130
+ ).status_code == 400
131
+ assert dispatcher.calls[0] == ("events.handle", [{"event": 1}])
132
+
133
+
134
+ async def test_cron_env_and_explicit_empty_args(monkeypatch):
135
+ monkeypatch.setenv("WORKFLOW_SLUG", "w")
136
+ monkeypatch.setenv("CRON_TASK", "cron.run")
137
+ monkeypatch.setenv("CRON_INPUT", '{"a":1}')
138
+ dispatcher = Dispatcher()
139
+ assert await run_cron(dispatcher=dispatcher) == {"runId": "run-1", "status": "running"}
140
+ assert dispatcher.calls[-1] == ("cron.run", [{"a": 1}])
141
+ result = await run_cron(dispatcher=dispatcher, args=[], wait=True, wait_timeout_ms=31)
142
+ assert result["results"] == [3]
143
+ assert dispatcher.calls[-1] == ("cron.run", [], 31)
144
+ with pytest.raises(ValueError, match="WORKFLOW_SLUG"):
145
+ await run_cron(workflow_slug="", dispatcher=dispatcher)
146
+
147
+
148
+ class Service:
149
+ def __init__(self, mode):
150
+ self.mode = mode
151
+ self.closed = False
152
+ self.calls = []
153
+ self.entered = asyncio.Event()
154
+
155
+ async def start_task(self, slug, args):
156
+ self.calls.append((slug, args))
157
+ return SimpleNamespace(id="run-1")
158
+
159
+ async def task_run_events(self, ids):
160
+ assert ids == ["run-1"]
161
+ self.entered.set()
162
+ try:
163
+ if self.mode == "timeout":
164
+ await asyncio.Event().wait()
165
+ if self.mode == "network_timeout":
166
+ raise TimeoutError("upstream timeout")
167
+ if self.mode == "end":
168
+ return
169
+ yield SimpleNamespace(id="other", status="failed", results=[])
170
+ yield SimpleNamespace(id="run-1", status="running", results=[])
171
+ yield SimpleNamespace(id="run-1", status="succeeded", results=[{"ok": True}])
172
+ finally:
173
+ self.closed = True
174
+
175
+
176
+ @pytest.mark.parametrize("mode", ["success", "timeout", "end", "network_timeout", "cancel"])
177
+ async def test_sdk_dispatch_wait_cleanup(mode):
178
+ service = Service(mode if mode != "cancel" else "timeout")
179
+ dispatcher = render_dispatcher(slug="workflow", client=SimpleNamespace(workflows=service))
180
+ if mode == "cancel":
181
+ task = asyncio.create_task(dispatcher.run("demo.run", [3], 10000))
182
+ await service.entered.wait()
183
+ task.cancel()
184
+ with pytest.raises(asyncio.CancelledError):
185
+ await task
186
+ elif mode in {"end", "network_timeout"}:
187
+ with pytest.raises((RuntimeError, TimeoutError)):
188
+ await dispatcher.run("demo.run", [3], 10000)
189
+ else:
190
+ result = await dispatcher.run("demo.run", [3], 1 if mode == "timeout" else 10000)
191
+ assert result["timedOut"] is (mode == "timeout")
192
+ if mode == "success":
193
+ assert result["results"] == [{"ok": True}]
194
+ assert service.calls == [("workflow/demo.run", [3])]
195
+ assert service.closed