Resource limits¶
A validator runs against untrusted values, so every recursive descent and every
error-reporting probe is bounded. A pathological input meets a gated limit and is
rejected cleanly; it never overflows the native stack, raises a Python
RecursionError, or hangs. The limits bound work driven by the value — the
untrusted part. A schema's own size (the width of a union, the number of declared
fields) is written by the developer and is trusted.
The bounds¶
- Schema build depth. The frontend descends one level past the construction
bound while compiling and rejects a schema that never reaches a leaf, with
NotImplementedError. A self-referential class is the shape that gets there, because its field type names the class; model it withrecursiveinstead. A schema that is merely too deep is refused by the construction bound below, which says so by name. - Schema construction size. Every way of growing a schema — the
Validatorconstructor, the|operator,union,intersection,complement,recursive, and the record transforms — is bounded at construction, so no sequence of calls can build a schema that overflows the stack or exhausts memory on a later walk. Three bounds apply, and passing any one raisesValueError:- depth — at most 128 levels of structural nesting (a chain built in a loop, such as repeatedly wrapping a validator in a set or a union). Every node counts one level, containers included, so a chain of 128 nested lists is the bound. A refinement counts one more on top of whatever it narrows, since the set it denotes is a node of its own: pinning a length on each list of such a chain reaches the bound at 64. Which marker the refinement carries makes no difference — the level belongs to the node;
- definitions — at most 128 recursive definitions (a chain of distinct
recursiveschemas, which the depth measure alone cannot see because a back edge counts as a leaf); - nodes — at most 100,000 total schema nodes (a shallow but exponentially wide schema, such as combining a validator with itself in a loop, which doubles its node count each step).
A real schema stays far under all three. Structural recursion belongs in
recursive, whose back edge does not count toward the depth.
The three numbers are importable, so code that sizes a schema against them reads them rather than repeating them.
from valgebra import MAX_DEFINITIONS, MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES
assert (MAX_SCHEMA_DEPTH, MAX_DEFINITIONS, MAX_SCHEMA_NODES) == (128, 128, 100_000)
recursion_limit: at most 128 levels of recursive
unfolding, and at most 512 levels of descent in total. The second is what
binds for a deep definition, because a recursive definition descends its whole
body once per level of the value — so the frames a value can ask for are the
product of the two, not either one. A level costs well under a kilobyte of
native stack, which puts the deepest walk inside the stack a platform gives a
thread. This holds on both the object path and the JSON path; an over-deep JSON
document is rejected by the parser as json_invalid.
The parser has a bound of its own — a couple of hundred levels of arrays and
objects — and it sits **between** the two: wider than the unfolding bound and
narrower than the descent one. A document therefore has three regions rather
than two. Inside the unfolding bound it is a member. Past it and inside the
parser's, the walk is what refuses, and the code is `recursion_limit`. Past
the parser's, the text stops being a document before the walk sees it, and
the code is `json_invalid`. The descent bound is not reachable through a
document at all, because the parser refuses first — it binds on the object
path, where there is no parser. `tests/test_adversarial_bounds.py` holds the
three regions and their order, which is what keeps this paragraph a
description of the tree rather than of two numbers that have since moved
past each other.
- Self-reference. A value that contains itself is caught by an
object-identity guard and fails with
recursion_looprather than looping forever. - Union error reporting. When a value misses a wide union, the error report
is bounded in two independent ways: it searches only a bounded number of
branches for the closest match, and
expectednames only a bounded number of labels before truncating with.... The two counts differ — a branch that is itself a union, such as a wideLiteral[...], contributes one branch and many labels — so each carries its own bound. Building the explanation stays bounded regardless of how wide the union is or how the value is shaped.
Rejection is clean, not catastrophic¶
from valgebra import ValidationError, Validator, recursive, union
schema = Validator(recursive(lambda j: union(int, [j])))
# A value nested far past the walk depth: a clean error, not a crash.
deep = 0
for _ in range(5000):
deep = [deep]
assert not schema.is_valid(deep)
try:
schema.validate(deep)
except ValidationError as error:
assert error.code == "recursion_limit"
# A value that contains itself: caught as a loop.
cyclic = []
cyclic.append(cyclic)
assert not schema.is_valid(cyclic)
# An over-deep JSON document: rejected by the parser.
assert not schema.is_valid_json("[" * 5000 + "1" + "]" * 5000)
Growing a schema in an unbounded loop is stopped at construction, before the growing schema can overflow the stack or exhaust memory on its next check:
from valgebra import Validator
composed = Validator(int)
try:
for _ in range(1000):
composed = composed | str
except ValueError as error:
assert "too deep" in str(error)
The worst-case timing of these shapes is measured by the adversarial benchmark and the bounds are correctness-tested, so each limit is an enforced, exercised guarantee rather than a comment.