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.

rustsrc/limits.rs
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>:

rust
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.

Zero is a policy, not a rejected configuration

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.

rustsrc/limits.rs
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.

rustsrc/limits.rs
impl CancellationToken {
    pub fn new() -> Self;
    pub fn cancel(&self);
    pub fn is_cancelled(&self) -> bool;
}
rust
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.

rust
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.

On bare wasm32-unknown-unknown, any explicit deadline fails closed

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.

json
{"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.

rust
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}.

CodePathCondition
unsupported_limits_version$.versionThe version is not 1.
unsupported_limits_profile$.profileThe 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.

rust
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#

CodeMeaningExtra 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.

Raising one limit may not be enough to render the result

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.

bash
cargo run --example bounded_parsing

Choosing 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_bytes and max_bundle_bytes to 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_work alongside them if you also render the result.
  • Running on a small stack? max_source_nesting and max_value_nesting bound lexical nesting before a recursive decoder runs. Rejecting costs constant stack; accepting still recurses. Note that raising max_value_nesting above 128 has no effect, because serde_json applies 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.

Next#