Working with limits
Profiles, builders, deadlines, cancellation and the portable serialized configuration.
Every public entry point in candid-core — every compile, parse,
validation, canonicalization, render and identity computation — runs under an
explicit resource policy. That policy is a Limits value you pass in,
not a global setting, not an environment variable, and not something the library
decides for you. It is what makes pointing this crate at a .did file
or a Contract document you did not write a bounded operation rather than an act of
faith.
A Limits carries 27 numeric limits plus one optional deadline. A
RuntimeContext wraps it together with a cancellation token. Limits are
base surface — no Cargo feature needed — and they never
participate in any Contract identity, so raising one changes what you accept, never
what you compute. This page is how to build and use them; the
limits reference has the full table of what each
one bounds and its default value.
Building a Limits#
Every field is private, and there is no struct literal. That is deliberate: adding a
limit later must not be a breaking change, and it cannot be one if no caller can
write an exhaustive literal. You start from a named profile and override individual
fields with chainable with_* builders.
use candid_core::{Limits, LimitsProfile};
let limits = LimitsProfile::InteractiveV1
.limits()
.with_max_input_bytes(64 * 1024)
.with_deadline_unix_ms(Some(2_000_000_000_000));
assert_eq!(limits.max_input_bytes(), 64 * 1024);
Limits::default() is exactly
LimitsProfile::InteractiveV1.limits(), so
Limits::default().with_max_input_bytes(512) is the same thing said
shorter. There is one getter per limit, named exactly after the field, and one
builder per limit, named with_<field>:
let limits = Limits::default().with_max_canonicalization_work(2_000_000);
let ceiling: usize = limits.max_canonicalization_work(); // getter, not a field
let profile = limits.profile(); // LimitsProfile::InteractiveV1
profile() reports the baseline the limits started from, not the final
values — overriding a field does not change it. LimitsProfile is
#[non_exhaustive] and InteractiveV1 is the only released
variant. Its numbers are frozen forever: a future tuning becomes a new variant, so
a stored configuration naming interactive_v1 means the same policy in
every later build.
Every limit accepts 0, and it means fail closed. A zero byte, count
or work limit rejects any input that consumes the resource at all.
with_max_diagnostics(0) is the one special case: it retains exactly
one out-of-band resource_limit_exceeded sentinel violation, so an
invalid input never yields an empty error collection.
RuntimeContext#
Most entry points come in three shapes: a bare one using
Limits::default(), a _with_limits one, and a
_with_context one. The context form is the widest, because a
RuntimeContext is a Limits plus the cooperative controls
that need a place to live.
pub struct RuntimeContext {
pub limits: Limits,
// cancellation: private
}
impl RuntimeContext {
pub fn new(limits: Limits) -> Self; // fresh, uncancelled token
pub fn with_cancellation(self, cancellation: CancellationToken) -> Self;
pub fn cancellation_token(&self) -> CancellationToken; // a clone of the context's token
}
limits is public; the cancellation token is not, so a struct literal
does not compile and adding a control later does not reopen exhaustive literals.
RuntimeContext::default() is
RuntimeContext::new(Limits::default()). Compilation entry points take
only the context form when they take a policy at all — there is no
compile_did_with_limits, because a caller setting limits on an
operation that can run long should also get the deadline and the token.
Cancellation#
CancellationToken is a cloneable flag. Clones share one atomic, so
cancelling any clone cancels them all — that is how you hold a handle on one thread
and cancel work running on another.
impl CancellationToken {
pub fn new() -> Self;
pub fn cancel(&self);
pub fn is_cancelled(&self) -> bool;
}use candid_core::{compile_did_with_context, CancellationToken, CompileOptions, Limits, RuntimeContext};
let source = "service : { ping: () -> (nat) query };";
let token = CancellationToken::new();
let context = RuntimeContext::new(Limits::default()).with_cancellation(token.clone());
// Hand the clone to whatever decides to give up: a request-cancelled callback,
// a UI stop button, a supervisor timer. Cancelling any clone cancels them all.
let stopper = token.clone();
std::thread::spawn(move || {
// ... later ...
stopper.cancel();
});
match compile_did_with_context(source, CompileOptions::default(), &context) {
Ok(compilation) => println!("compiled {}", compilation.contract().contract_id()),
Err(error) => {
let cancelled = error
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "operation_cancelled");
if cancelled {
eprintln!("compile abandoned on request");
}
}
}
Cancellation is cooperative. Nothing is interrupted mid-instruction;
the internal budget checks the flag at every checkpoint, and a cancelled operation
fails closed with the code operation_cancelled rather than returning a
partial result. A custom SourceResolver that does long-running work
should override load_with_context and checkpoint internally, so a
cancellation raised during a slow load is observed rather than waited out — the
default implementation only checkpoints before and after your load.
Deadlines#
A deadline is configured as a Unix timestamp in milliseconds, and
None — the profile default — means unbounded.
use candid_core::{Limits, RuntimeContext};
use std::time::{SystemTime, UNIX_EPOCH};
let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64;
let limits = Limits::default().with_deadline_unix_ms(Some(now_ms + 250));
let context = RuntimeContext::new(limits);
It is configured in wall-clock terms but enforced against a
monotonic clock: the deadline is snapshotted once when an operation's budget is
created, so a system clock adjustment cannot extend or shorten an operation
mid-flight. An elapsed deadline fails closed with
operation_deadline_exceeded, and
Limits::deadline_exceeded() lets you ask directly. A deadline at or
before the current time — Some(0) included — makes every bounded
operation fail before performing work.
That target supplies no clock at all, and the standard clock functions panic there
rather than returning an error. An explicit deadline must not silently become
unbounded and must not abort the module, so it is reported as already elapsed and
reaches you as the same operation_deadline_exceeded an elapsed native
deadline produces. None stays unbounded exactly as it does natively,
and neither cancellation nor any quantitative limit is affected.
The portable serialized configuration#
A Limits does not serialize as a bare field map. It serializes as a
versioned configuration document that names the profile it started from and lists
only the fields that differ from that profile's frozen baseline.
{"version":1,"profile":"interactive_v1","overrides":{}}
That is exactly how Limits::default() serializes.
version pins the schema (LIMITS_CONFIG_VERSION, currently
1); profile names the baseline; overrides
carries the explicit differences as fixed-width u64 values, so the same
document configures identical policy on every supported 32- and 64-bit host. An
override equal to the baseline is normalized away — the profile numbers are frozen,
so omission and an equal explicit value are the same policy forever.
use candid_core::{Limits, RuntimeContext};
// Write it out — e.g. into a project config file, or across a process boundary.
let limits = Limits::default()
.with_max_input_bytes(65_536)
.with_max_sources(32);
let document = serde_json::to_string(&limits)?;
// {"version":1,"profile":"interactive_v1","overrides":{"max_input_bytes":65536,"max_sources":32}}
// Read it back.
let restored: Limits = serde_json::from_str(&document)?;
assert_eq!(restored, limits);
// A RuntimeContext serializes as {"limits": <that document>}; the cancellation
// token is host-local bookkeeping and is never serialized.
let context: RuntimeContext = serde_json::from_str(r#"{"limits":{"version":1,"profile":"interactive_v1","overrides":{}}}"#)?;
Adding a new limit does not bump version. Fields are private precisely
so that adding one is not a breaking change, and an older document carries no
override for it.
Rejections are structured#
Unknown top-level fields, unknown override fields, unsupported versions and unknown
profiles are all rejected rather than ignored, and an override that does not fit the
host's usize is rejected rather than truncated or wrapped. Convert
explicitly with TryFrom<LimitsConfig> when you need the structured
LimitsConfigError instead of a serde error string; it has
code(), path() and message() accessors and
displays as {code} at {path}: {message}.
| Code | Path | Condition |
|---|---|---|
unsupported_limits_version | $.version | The version is not 1. |
unsupported_limits_profile | $.profile | The profile name is unknown to this build. |
limit_override_unrepresentable | $.overrides.<field> | The override exceeds this platform's usize::MAX. |
A worked example: tighten a limit and read the failure#
Tighten max_source_bytes, compile a source that exceeds it, and handle
the structured result. Nothing here inspects a message string — the
code, the phase and the
{resource, limit, observed} triple are the stable surface; message text
is not.
use candid_core::{compile_did_with_context, CompileOptions, Limits, RuntimeContext};
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let source = "service : { ping: () -> (nat) query };";
// A policy far tighter than the 1 MiB default, to make the failure certain.
let context = RuntimeContext::new(Limits::default().with_max_source_bytes(8));
let error = match compile_did_with_context(source, CompileOptions::default(), &context) {
Ok(_) => return Err("expected the tightened limit to reject this source".into()),
Err(error) => error,
};
let diagnostic = error
.diagnostics
.first()
.ok_or("a CompileError always carries at least one diagnostic")?;
assert_eq!(diagnostic.code, "resource_limit_exceeded");
match &diagnostic.resource_limit {
Some(info) => println!(
"rejected in phase {:?}: resource={} limit={} observed={}",
diagnostic.phase, info.resource, info.limit, info.observed
),
None => println!("rejected in phase {:?}: {}", diagnostic.phase, diagnostic.code),
}
// Raising the one limit that fired is enough here, because nothing else was
// exhausted.
let context = RuntimeContext::new(Limits::default());
let compilation = compile_did_with_context(source, CompileOptions::default(), &context)?;
println!("accepted: {}", compilation.contract().contract_id());
Ok(())
}
That reports resource source_bytes in phase Load. The
resource name is usually the limit's own name without its
max_ prefix, which is how you map a failure back to the builder that
raises it: source_bytes to with_max_source_bytes,
input_bytes to with_max_input_bytes,
import_depth to with_max_import_depth. A few counters
report a narrower name than the limit they charge against — the provenance
sidecar's string budget reports source_string_bytes while charging
max_string_bytes, and an envelope extension reports
extension_bytes while charging max_value_bytes — so read
the resource as "which counter fired", not as a literal builder name.
Three ways a bounded operation fails closed#
| Code | Meaning | Extra data |
|---|---|---|
resource_limit_exceeded |
A counter ran out. | A ResourceLimitInfo { resource, limit, observed }, both numbers fixed-width u64. |
operation_cancelled |
The cancellation token was set. | None. |
operation_deadline_exceeded |
The deadline elapsed, or could not be measured. | None. |
All three reach a compile as CompileError.diagnostics and reach the
model APIs as ContractValidationError.violations, where each item is
the same Diagnostic type. Full field list in
the diagnostics reference.
Validation happens before rendering, and the rendered length is charged against
max_canonicalization_work on top of whatever gated construction. So a
caller who raised max_input_bytes to parse a large document may still
fail to serialize it, with a failure naming
canonicalization_work rather than the limit they raised.
examples/bounded_parsing.rs demonstrates both halves; see
Building and validating Contracts.
cargo run --example bounded_parsingChoosing values#
The defaults are named InteractiveV1 because that is what they are
tuned for: parsing and validating untrusted documents in an editor, a CLI, or an
agent context on an ordinary desktop host. They are a starting point, not a
recommendation for every deployment.
-
Accepting uploads from strangers? Tighten
max_input_bytes,max_source_bytesandmax_bundle_bytesto what your product actually needs, and set a deadline. Those are the knobs that bound peak allocation and wall-time exposure. -
Processing your own large generated interfaces? Raise the
structural limits that fire — and expect to raise
max_canonicalization_workalongside them if you also render the result. -
Running on a small stack?
max_source_nestingandmax_value_nestingbound lexical nesting before a recursive decoder runs. Rejecting costs constant stack; accepting still recurses. Note that raisingmax_value_nestingabove 128 has no effect, becauseserde_jsonapplies a fixed 128-frame recursion ceiling underneath that this crate deliberately does not disable.
The complete table — all 27 limits, their default values and what each one bounds — is on the limits reference page.