Skip to content

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 with recursive instead. 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 Validator constructor, 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 raises ValueError:
    • 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 recursive schemas, 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)
- Value-walk depth. Two bounds hold the walk inside the stack, and reaching either fails with 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_loop rather 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 expected names only a bounded number of labels before truncating with .... The two counts differ — a branch that is itself a union, such as a wide Literal[...], 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.