Service¶
Our own service runs this package on our own machines and speaks the session protocol over WebSocket and HTTP, with the reference decision service as an HTTP API beside it. Open this page when you change the service or embed its application in another ASGI server; to install and run it, read Run our own service.
Everything below needs the service extra
(pip install 'grounded-decision[service]'), except the configuration,
which imports only the session layer.
service ¶
Our own service: the package and its protocol over HTTP and WebSocket.
grounded-decision serve runs it (make serve locally); it runs on
our own machines behind our own nginx, which proxies /api/ and
/ws/ beside the static site (docs/how-to/run-the-service.md). It
speaks the session protocol of :mod:grounded_decision.session with the
same :class:~grounded_decision.session.protocol.Endpoint, one session
per WebSocket, so a front end switches between the browser's worker and
the service by one setting; it also serves the reference decision
service (examples/reference_service.py) as an HTTP API. It needs the
service extra (pip install 'grounded-decision[service]'); nothing
else in the package imports it.
Modules and what they own:
:mod:~grounded_decision.service.config
:class:~.config.ServiceConfig and :class:~.config.ServiceLimits:
every setting, from a flag, the environment or a default.
:mod:~grounded_decision.service.limits
:class:~.limits.Refusal, :class:~.limits.WorkLimits,
:class:~.limits.RateLimiter, :class:~.limits.SessionSlots: the
limits, enforced as protocol errors.
:mod:~grounded_decision.service.connection
:class:~.connection.ProtocolConnection: one client's session;
:class:~.connection.PythonThreads: where requests run, with a deadline.
:mod:~grounded_decision.service.app
:class:~.app.ServiceApp: the ASGI application and its routes.
:mod:~grounded_decision.service.logs
JSON log lines on standard output, with no visitor data.
:mod:~grounded_decision.service.server
:class:~.server.ServiceServer (uvicorn, configured) and
:class:~.server.BackgroundService (on a thread, for tests).
ConfigError ¶
Bases: ValueError
A setting has a value the service cannot use; the message names it.
ServiceConfig
dataclass
¶
ServiceConfig(
host: str = "127.0.0.1",
port: int = 8700,
database: Path = (
lambda: (
Path(gettempdir())
/ "grounded-decision-service.sqlite3"
)
)(),
seed_demo_data: bool = False,
shadow: bool = False,
threads: int = 4,
log_level: str = "info",
limits: ServiceLimits = ServiceLimits(),
)
Everything the service is started with.
Attributes:
| Name | Type | Description |
|---|---|---|
host |
str
|
The interface to listen on ( |
port |
int
|
The port. |
database |
Path
|
The decision API's SQLite file (its tables: state, audit, reviews, outbox, keys, the evidence tables). |
seed_demo_data |
bool
|
Load the reference service's demo fraud ring into the database at start-up. |
shadow |
bool
|
Run the decision API in shadow mode (record, execute nothing). |
threads |
int
|
Threads running Python for the requests (the event loop stays free to read and write sockets meanwhile). |
log_level |
str
|
|
limits |
ServiceLimits
|
What one client may ask. |
add_arguments
classmethod
¶
Add one flag per setting to parser (each defaults to unset).
Source code in src/grounded_decision/service/config.py
load
classmethod
¶
load(
arguments: Namespace | None,
environment: Mapping[str, str],
) -> ServiceConfig
The configuration from flags, then the environment, then the defaults.
Raises:
| Type | Description |
|---|---|
ConfigError
|
A value cannot be read or is out of range. |
Source code in src/grounded_decision/service/config.py
with_limits ¶
with_limits(**changes: Any) -> ServiceConfig
This configuration with some limits changed (tests and the load test).
summary ¶
The settings, for the start-up log line (no secrets: there are none).
Source code in src/grounded_decision/service/config.py
ServiceLimits
dataclass
¶
ServiceLimits(
max_message_bytes: int = 1024 * 1024,
max_sessions: int = 64,
messages_per_second: float = 100.0,
burst: int = 200,
request_timeout: float = 30.0,
idle_timeout: float = 600.0,
max_steps: int = MAX_STEPS_PER_REQUEST,
max_audit_moves: int = 1000,
max_compare_games: int = 20,
max_compare_seeds: int = 100,
)
What one client may ask of the service.
Attributes:
| Name | Type | Description |
|---|---|---|
max_message_bytes |
int
|
The largest request, in bytes of UTF-8. A larger
one is answered with a |
max_sessions |
int
|
Open WebSocket sessions and HTTP requests in flight
in this process; past it a client gets a |
messages_per_second |
float
|
Each connection's sustained rate. A message
over it is answered with a |
burst |
int
|
How many messages a connection may send at once before the rate applies. |
request_timeout |
float
|
Seconds one request may take (queueing
included). Past it the client gets a |
idle_timeout |
float
|
Seconds a WebSocket may stay silent before it is closed. |
max_steps |
int
|
The most moves one |
max_audit_moves |
int
|
The most moves one |
max_compare_games |
int
|
The most games one |
max_compare_seeds |
int
|
The most seeds a comparison may have. |
frame_cap_bytes
property
¶
The WebSocket frame size past which the server drops the connection.
Above :attr:max_message_bytes, so a request a little too big
still gets its protocol error; only a far larger one is cut off.
config ¶
What the service is told at start-up: one setting each, from a flag, the environment or a default.
Every setting has one name, from which its command-line flag
(--max-sessions) and its environment variable
(GD_SERVICE_MAX_SESSIONS) are derived; a flag beats the environment,
which beats the default. docs/how-to/run-the-service.md lists them.
Concepts and the classes that own them:
:class:ServiceLimits
What one client may ask of the service: message size, sessions,
rate, time and the size of the work in one request.
:class:ServiceConfig
Where it listens, where it keeps the decision API's data, how many
threads run Python, and its limits.
:class:Setting
One setting's name, kind, help, flag and environment variable.
ENVIRONMENT_PREFIX
module-attribute
¶
Every environment variable the service reads starts with this.
SETTINGS
module-attribute
¶
SETTINGS: tuple[Setting, ...] = (
Setting("host", "text", "interface to listen on"),
Setting("port", "int", "port to listen on"),
Setting(
"database",
"path",
"SQLite file of the decision API",
),
Setting(
"seed_demo_data",
"flag",
"load the demo fraud ring into the database",
),
Setting(
"shadow",
"flag",
"decision API in shadow mode: record, execute nothing",
),
Setting("threads", "int", "threads running Python"),
Setting(
"log_level", "text", "debug, info, warning or error"
),
Setting(
"max_message_bytes",
"int",
"largest request in bytes",
"limits",
),
Setting(
"max_sessions",
"int",
"open sessions in this process",
"limits",
),
Setting(
"messages_per_second",
"float",
"each connection's rate",
"limits",
),
Setting(
"burst",
"int",
"messages a connection may send at once",
"limits",
),
Setting(
"request_timeout",
"float",
"seconds one request may take",
"limits",
),
Setting(
"idle_timeout",
"float",
"seconds a socket may stay silent",
"limits",
),
Setting(
"max_steps",
"int",
"most moves one step request plays",
"limits",
),
Setting(
"max_audit_moves",
"int",
"most moves one audit request plays",
"limits",
),
Setting(
"max_compare_games",
"int",
"most games one compare request plays",
"limits",
),
Setting(
"max_compare_seeds",
"int",
"most seeds one comparison has",
"limits",
),
)
Every setting, in --help order.
ConfigError ¶
Bases: ValueError
A setting has a value the service cannot use; the message names it.
ServiceLimits
dataclass
¶
ServiceLimits(
max_message_bytes: int = 1024 * 1024,
max_sessions: int = 64,
messages_per_second: float = 100.0,
burst: int = 200,
request_timeout: float = 30.0,
idle_timeout: float = 600.0,
max_steps: int = MAX_STEPS_PER_REQUEST,
max_audit_moves: int = 1000,
max_compare_games: int = 20,
max_compare_seeds: int = 100,
)
What one client may ask of the service.
Attributes:
| Name | Type | Description |
|---|---|---|
max_message_bytes |
int
|
The largest request, in bytes of UTF-8. A larger
one is answered with a |
max_sessions |
int
|
Open WebSocket sessions and HTTP requests in flight
in this process; past it a client gets a |
messages_per_second |
float
|
Each connection's sustained rate. A message
over it is answered with a |
burst |
int
|
How many messages a connection may send at once before the rate applies. |
request_timeout |
float
|
Seconds one request may take (queueing
included). Past it the client gets a |
idle_timeout |
float
|
Seconds a WebSocket may stay silent before it is closed. |
max_steps |
int
|
The most moves one |
max_audit_moves |
int
|
The most moves one |
max_compare_games |
int
|
The most games one |
max_compare_seeds |
int
|
The most seeds a comparison may have. |
frame_cap_bytes
property
¶
The WebSocket frame size past which the server drops the connection.
Above :attr:max_message_bytes, so a request a little too big
still gets its protocol error; only a far larger one is cut off.
ServiceConfig
dataclass
¶
ServiceConfig(
host: str = "127.0.0.1",
port: int = 8700,
database: Path = (
lambda: (
Path(gettempdir())
/ "grounded-decision-service.sqlite3"
)
)(),
seed_demo_data: bool = False,
shadow: bool = False,
threads: int = 4,
log_level: str = "info",
limits: ServiceLimits = ServiceLimits(),
)
Everything the service is started with.
Attributes:
| Name | Type | Description |
|---|---|---|
host |
str
|
The interface to listen on ( |
port |
int
|
The port. |
database |
Path
|
The decision API's SQLite file (its tables: state, audit, reviews, outbox, keys, the evidence tables). |
seed_demo_data |
bool
|
Load the reference service's demo fraud ring into the database at start-up. |
shadow |
bool
|
Run the decision API in shadow mode (record, execute nothing). |
threads |
int
|
Threads running Python for the requests (the event loop stays free to read and write sockets meanwhile). |
log_level |
str
|
|
limits |
ServiceLimits
|
What one client may ask. |
add_arguments
classmethod
¶
Add one flag per setting to parser (each defaults to unset).
Source code in src/grounded_decision/service/config.py
load
classmethod
¶
load(
arguments: Namespace | None,
environment: Mapping[str, str],
) -> ServiceConfig
The configuration from flags, then the environment, then the defaults.
Raises:
| Type | Description |
|---|---|
ConfigError
|
A value cannot be read or is out of range. |
Source code in src/grounded_decision/service/config.py
with_limits ¶
with_limits(**changes: Any) -> ServiceConfig
This configuration with some limits changed (tests and the load test).
summary ¶
The settings, for the start-up log line (no secrets: there are none).
Source code in src/grounded_decision/service/config.py
Setting
dataclass
¶
Setting(
name: str,
kind: SettingKind,
help: str,
group: Literal["service", "limits"] = "service",
)
One setting: its field, kind and help; its flag and variable follow from its name.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The field of :class: |
kind |
SettingKind
|
How its text is read. |
help |
str
|
One line for |
group |
Literal['service', 'limits']
|
|
read ¶
The value from the flag, else the environment, else None (the default).
Source code in src/grounded_decision/service/config.py
limits ¶
The service's limits, enforced as protocol errors.
The service answers every request it will not run with an error
reply of the protocol (docs/reference/protocol.md), the same shape
the engine itself sends, so a client handles one kind of error:
======================== =========== ==================================
Limit Code Owner
======================== =========== ==================================
Message too large message :class:Refusal (too_large)
Too much work in one message :class:WorkLimits
request (count, or spec
moves, games,
seeds)
Too many messages session :class:RateLimiter
Too many sessions session :class:SessionSlots
Request took too long session :class:Refusal (timeout)
======================== =========== ==================================
session is the protocol's "not now": the request may succeed later.
Refusal
dataclass
¶
A request the service will not run, and why.
Attributes:
| Name | Type | Description |
|---|---|---|
limit |
str
|
Which limit ( |
code |
ErrorCode
|
The protocol error code. |
message |
str
|
One sentence for the person, naming the limit. |
status |
HTTPStatus
|
The HTTP status it is sent with over |
reply ¶
The protocol error reply, as JSON text.
WorkLimits
dataclass
¶
WorkLimits(limits: ServiceLimits)
How much work one request may ask for, below the protocol's own maxima.
Checked on the decoded message before the engine sees it; a value that is not a whole number is left for the engine's own validation, so its message stays the engine's.
Attributes:
| Name | Type | Description |
|---|---|---|
limits |
ServiceLimits
|
The service's limits. |
check ¶
The refusal for a request that asks for too much, or None.
Source code in src/grounded_decision/service/limits.py
RateLimiter
dataclass
¶
A token bucket: burst messages at once, then rate a second.
Attributes:
| Name | Type | Description |
|---|---|---|
rate |
float
|
Tokens added per second. |
burst |
int
|
The bucket's size (and how full it starts). |
clock |
Callable[[], float]
|
Seconds, monotonic (injected by tests). |
allow ¶
Takes a token if there is one; False means the message is over the rate.
Source code in src/grounded_decision/service/limits.py
SessionSlots
dataclass
¶
How many sessions (WebSockets, HTTP requests in flight) the process holds.
Used only from the event loop, so it needs no lock.
Attributes:
| Name | Type | Description |
|---|---|---|
capacity |
int
|
The most at once. |
acquire ¶
reply_text ¶
reply_text(reply: JsonValue) -> str
A reply as the protocol's compact JSON text (what Endpoint.handle_text writes).
request_id ¶
request_id(message: JsonValue) -> int | None
The request's id, as the engine reads it (an integer, never a boolean).
Source code in src/grounded_decision/service/limits.py
connection ¶
One client's session over the service, and the threads that run its Python.
A WebSocket is one :class:ProtocolConnection: its own
:class:~grounded_decision.session.protocol.Endpoint (so its own game,
comparison and audit), its own rate, and the service's limits, checked
before the engine sees a message. An HTTP POST /api/ is a connection
for one message. Requests run on :class:PythonThreads, so the event
loop keeps reading and writing every other socket meanwhile, and each
has a deadline.
The reply to a request the service runs is exactly what
Endpoint.handle_text returns for the same text (the golden tests
compare them byte for byte); a request it does not run gets a
:class:~grounded_decision.service.limits.Refusal.
PythonThreads ¶
The threads that run requests; the event loop awaits each with a deadline.
A request past its deadline is abandoned, not stopped (Python cannot
stop a thread): the bounded work per request (:class:WorkLimits)
bounds how long it keeps its thread, and its session is dropped.
Source code in src/grounded_decision/service/connection.py
run
async
¶
work() on a thread, within timeout seconds.
Raises:
| Type | Description |
|---|---|
TimeoutError
|
It did not finish in time (if it had not started, it never will). |
Source code in src/grounded_decision/service/connection.py
Answer
dataclass
¶
Answer(
text: str,
refusal: Refusal | None = None,
close: bool = False,
)
What the service sends back for one message.
Attributes:
| Name | Type | Description |
|---|---|---|
text |
str
|
The reply, as JSON text. |
refusal |
Refusal | None
|
Why the message was not run, or |
close |
bool
|
The connection must close after the reply (its session is lost). |
ProtocolConnection
dataclass
¶
ProtocolConnection(
threads: PythonThreads,
limits: ServiceLimits,
rate: RateLimiter | None = None,
endpoint: Endpoint = Endpoint(),
messages: int = 0,
refused: int = 0,
)
One client's session: its endpoint, its rate and the service's limits.
Messages are answered one at a time, in order (the protocol's replies come in request order).
Attributes:
| Name | Type | Description |
|---|---|---|
threads |
PythonThreads
|
Where the engine runs. |
limits |
ServiceLimits
|
The service's limits. |
rate |
RateLimiter | None
|
The connection's rate, or |
endpoint |
Endpoint
|
The session's engine. |
messages |
int
|
Messages received. |
refused |
int
|
Messages answered with a refusal. |
answer
async
¶
answer(text: str) -> Answer
The reply to one message (a protocol request as JSON text).
Source code in src/grounded_decision/service/connection.py
refuse ¶
A refusal of a message that was never read (a binary frame).
app ¶
The service as an ASGI application: its routes, over HTTP and WebSocket.
========================== ==============================================
Route What it does
========================== ==============================================
GET /healthz {"status": "ok", ...}: alive, versions,
sessions in use
POST /api/ One protocol request, one reply (a fresh
session each time: describe, open,
the demos)
/api/decisions/... The reference decision service's HTTP API
(POST /api/decisions/decide and the rest:
the same paths and bodies as
examples/reference_service.py)
/ws/ (WebSocket) A protocol session: one per connection,
every request and reply a text message
========================== ==============================================
Raw ASGI, no framework: four routes do not need one (design decision
D77). :class:ServiceApp owns the routes, the session slots, the
threads and the decision API; every request it will not run is answered
with a protocol error (:mod:~grounded_decision.service.limits).
DECISIONS_PREFIX
module-attribute
¶
Where the reference decision service's paths are mounted.
TRY_AGAIN_LATER
module-attribute
¶
The WebSocket close code for "at capacity" and "too slow" (RFC 6455, IANA registry).
GOING_AWAY
module-attribute
¶
The close code for an idle socket and for a server shutting down.
ServiceApp ¶
ServiceApp(
config: ServiceConfig,
*,
decisions: DecisionService | None = None,
clock: Callable[[], float] = monotonic,
version: str | None = None,
)
The service: health, protocol over HTTP and WebSocket, the decision API.
Stateless apart from each WebSocket's session, which lives as long as its connection. Use one per process.
Attributes:
| Name | Type | Description |
|---|---|---|
config |
What it was started with. |
|
slots |
Sessions in use (WebSockets, and HTTP requests in flight). |
|
threads |
Where requests run. |
|
decisions |
The decision API's routes. |
Source code in src/grounded_decision/service/app.py
logs ¶
Structured logs: one JSON object per line on standard output, with no visitor data.
What is logged: the service starting and stopping with its settings,
each connection's end (how long, how many messages, how many refused),
each refusal by limit, each HTTP request's route, status and time, and
errors with their traceback. What is never logged: addresses, headers,
user agents, message contents, game specs or moves. The access logs of
the ASGI server (which carry the client's address) are off, its own
logger is kept at warning, and the reference decision service's
per-decision line (which carries the caller's entity ids) is kept at
warning too.
Concepts and the classes that own them:
:class:JsonFormatter
A log record as one JSON line.
:class:EventLog
The service's events, each with its fields.
QUIET_LOGGERS
module-attribute
¶
QUIET_LOGGERS = (
"uvicorn",
"uvicorn.error",
"uvicorn.access",
"grounded_decision.reference_service",
)
Loggers kept at warning: their info lines carry addresses or callers' ids.
JsonFormatter ¶
Bases: Formatter
{"time", "level", "logger", "event", ...fields} on one line.
The fields come from the record's fields attribute (what
:class:EventLog passes); an exception adds error and traceback.
EventLog
dataclass
¶
The service's events, each a message and its fields.
Attributes:
| Name | Type | Description |
|---|---|---|
logger |
Logger
|
Where they go. |
configure_logging ¶
Send every log record to stream (standard output) as JSON lines.
Source code in src/grounded_decision/service/logs.py
quiet_loggers ¶
Keeps :data:QUIET_LOGGERS at warning or above (their info lines name visitors).
Source code in src/grounded_decision/service/logs.py
server ¶
Running the service: in the foreground (grounded-decision serve) or on a thread (tests).
The ASGI server is uvicorn with its sans-I/O websockets protocol and
h11 for HTTP, configured here and nowhere else: no access log, no
Server header, the client's address never read from proxy headers,
WebSocket frames capped above the message limit, a short keep-alive and
a graceful shutdown that lets requests in flight finish.
Concepts and the classes that own them:
:class:ServiceServer
The configured server around one :class:~.app.ServiceApp.
:class:BackgroundService
A server on a thread, on a free port, stopped on exit (tests, the
load test).
ServiceServer
dataclass
¶
ServiceServer(
config: ServiceConfig, app: ServiceApp | None = None
)
uvicorn, configured for the service.
Attributes:
| Name | Type | Description |
|---|---|---|
config |
ServiceConfig
|
The service's settings. |
app |
ServiceApp | None
|
The application it serves (made from |
BackgroundService ¶
BackgroundService(
config: ServiceConfig, app: ServiceApp | None = None
)
The service on a thread, on a free port of 127.0.0.1; a context manager.
Attributes:
| Name | Type | Description |
|---|---|---|
config |
Its settings (the port is replaced by a free one). |
|
server |
The running server. |
Source code in src/grounded_decision/service/server.py
serve ¶
serve(config: ServiceConfig) -> None
grounded-decision serve: JSON logs on stdout, then serve in the foreground.
Source code in src/grounded_decision/service/server.py
load ¶
A load test of a running service: many WebSocket sessions stepping snake at once.
Each session opens a snake game and asks for one move per request, as fast as the replies come, until it has made its requests; a game that ends is opened again. The report gives the throughput (requests and moves a second, all sessions together) and the latency of one request (median, 95th and 99th percentile, worst), measured by the client::
python -m grounded_decision.service.load --url ws://127.0.0.1:8700/ws/ --sessions 32
make service-load runs it against a service it starts;
tests/test_service.py runs a short one with a generous bound.
Concepts and the classes that own them:
:class:LoadTest
What to run: the URL, the sessions, the requests each, the game.
:class:LoadReport
What it measured.
LoadReport
dataclass
¶
LoadReport(
sessions: int,
requests: int,
moves: int,
errors: int,
seconds: float,
latencies_ms: tuple[float, ...],
)
What a load test measured.
Attributes:
| Name | Type | Description |
|---|---|---|
sessions |
int
|
Sessions run at once. |
requests |
int
|
|
moves |
int
|
Moves those requests played. |
errors |
int
|
Requests answered with an error (other than a finished game). |
seconds |
float
|
Wall-clock time from the first open to the last reply. |
latencies_ms |
tuple[float, ...]
|
Every request's round trip, in milliseconds, sorted. |
percentile ¶
The latency below which share of the requests fall (nearest rank).
Source code in src/grounded_decision/service/load.py
lines ¶
The report, for people.
Source code in src/grounded_decision/service/load.py
LoadTest
dataclass
¶
Sessions stepping snake at once against a service.
Attributes:
| Name | Type | Description |
|---|---|---|
url |
str
|
The service's WebSocket ( |
sessions |
int
|
How many at once. |
requests |
int
|
|
size |
str
|
The snake board. |
count |
int
|
Moves per request. |
wait_for_health ¶
Waits until the service behind WebSocket url answers GET /healthz.
Raises:
| Type | Description |
|---|---|
TimeoutError
|
It did not within |
Source code in src/grounded_decision/service/load.py
main ¶
python -m grounded_decision.service.load: run a load test and print its report.