How the @contract Decorator Retry Mechanism Works in SymbolicAI

The @contract decorator in SymbolicAI automatically retries failed LLM outputs up to 8 times by default, using exponential back-off and a remedy LLM to fix validation errors against Pydantic schemas before optionally failing gracefully.

The extensityai/symbolicai library implements Design-by-Contract principles for neurosymbolic programming. When you apply the @contract decorator to a class, it wraps the forward method with a sophisticated retry mechanism that validates JSON outputs against declared LLMDataModel schemas. If validation fails, the system does not immediately raise an error; instead, it enters a retry loop that invokes a remedy LLM to correct the output, making the retry mechanism with the @contract decorator a critical resilience feature for production LLM applications.

Where Retry Parameters Are Configured

The default retry behavior lives in symai/strategy.py within the contract class definition. The decorator maintains a class-level dictionary that specifies how many attempts to make and how long to wait between them:


# symai/strategy.py – default retry defaults

_default_retry_params: ClassVar[dict[str, int | float | bool]] = {
    "tries": 8,
    "delay": 0.015,
    "backoff": 1.25,
    "jitter": 0.0,
    "max_delay": 0.25,
    "graceful": False,
}

Lines 37-44 define these defaults, which are merged with any user-supplied remedy_retry_params during decorator instantiation. The merged configuration is stored in self.remedy_retry_params and passed to the internal TypeValidationFunction instance:

self.f_type_validation_remedy = TypeValidationFunction(
    accumulate_errors=accumulate_errors,
    verbose=verbose,
    retry_params=remedy_retry_params,
)

This initialization occurs at lines 20-24 of the contract.__init__ method, ensuring that every wrapped class carries its own retry policy.

The Core Retry Loop in TypeValidationFunction

The actual retry logic resides in TypeValidationFunction._run_validation_attempts inside symai/strategy.py. This method implements a "try-catch-repeat" pattern that attempts validation up to tries + 1 times (the initial attempt plus the specified retries):

total_attempts = self.retry_params["tries"] + 1
for attempt in range(total_attempts):
    # try to parse the JSON into the output model

    result = self.output_data_model.model_validate_json(json_str, ...)
    # optional semantic checks …

    break          # success → exit loop

except Exception as error:
    # failure → invoke remedy handling

    json_str = self._handle_failed_validation_attempt(...)

Lines 85-100 contain this loop structure. On the first iteration, the system attempts to validate the zero-shot LLM output. If model_validate_json raises an exception—indicating malformed JSON or schema violations—the flow jumps to the exception handler, which triggers the remedy pipeline and schedules a retry.

Exponential Back-Off and Jitter Implementation

Between failed attempts, the decorator applies intelligent back-off to avoid overwhelming the LLM API. The _pause method at lines 28-36 calculates the sleep duration using exponential back-off with optional jitter and a hard ceiling:

def _pause(self, attempt):
    base = self.retry_params["delay"] * (self.retry_params["backoff"] ** attempt)
    jit = (
        np.random.uniform(*self.retry_params["jitter"])
        if isinstance(self.retry_params["jitter"], tuple)
        else self.retry_params["jitter"]
    )
    _delay = min(base + jit, self.retry_params["max_delay"])
    time.sleep(_delay)

This implementation multiplies the base delay by backoff raised to the power of the current attempt number, adds random jitter if configured (accepting either a float or a tuple for range specification), and caps the result at max_delay. For example, with the defaults (delay=0.015, backoff=1.25), the delays between attempts progress roughly as 0.015s, 0.019s, 0.024s, up to the 0.25s maximum.

Remedy LLM Invocation

When validation fails, _handle_failed_validation_attempt (referenced at lines 58-68) constructs a remedy prompt that explains the validation errors to a secondary LLM call. This "remedy function" attempts to repair the malformed JSON:

context = self.remedy_prompt(prompt=prompt, output=json_str,
                            errors="\n".join(errors) if self.accumulate_errors else error_str)
self.remedy_function.clear()
self.remedy_function.adapt(context)
json_str = self.remedy_function(seed=remedy_seeds[attempt_index], **kwargs).value

The remedy_function is a pre-configured Function instance from symai/components.py that calls the active LLM engine. It receives the original output, the specific validation errors, and optionally accumulated error history (if accumulate_errors=True). The remedied JSON string then feeds back into the next iteration of the validation loop.

Graceful Failure Handling

If all retry attempts exhaust without successful validation, _handle_validation_failure at lines 18-24 determines the final behavior based on the graceful parameter:

if self.retry_params["graceful"]:
    return
raise TypeValidationError(...)

When graceful=True, the decorator silently returns None rather than raising a TypeValidationError, allowing applications to degrade gracefully. When graceful=False (the default), the system raises a comprehensive error containing the last validation failure details.

Practical Example with Custom Retry Settings

You can override the default retry policy by passing a custom remedy_retry_params dictionary to the decorator. This example configures 4 attempts with aggressive back-off and random jitter:

from symai import contract, LLMDataModel, field

class AnswerModel(LLMDataModel):
    answer: str = field(..., description="The answer to the question")

@contract(
    pre_remedy=True,
    post_remedy=True,
    remedy_retry_params={
        "tries": 4,
        "delay": 0.1,
        "backoff": 2.0,
        "jitter": (0.0, 0.05),
        "max_delay": 2.0,
        "graceful": False,
    },
    verbose=True,
)
class QA:
    def __init__(self, engine):
        self.engine = engine

    def forward(self, input: AnswerModel) -> AnswerModel:
        return self.engine.run_prompt(f"Answer the question: {input.question}")

qa = QA(engine=my_neurosymbolic_engine)
result = qa.forward(AnswerModel(question="What is 2+2?"))

In this configuration:

  1. The decorator creates a TypeValidationFunction with tries=4, meaning 5 total validation attempts (initial plus 4 retries).
  2. The back-off multiplier is 2.0, creating delays of 0.1s, 0.2s, 0.4s, and 0.8s (capped at 2.0s).
  3. Random jitter between 0.0 and 0.05 seconds prevents thundering herd problems.
  4. If all attempts fail, the decorator raises TypeValidationError because graceful is disabled.

Summary

  • symai/strategy.py houses the contract decorator and TypeValidationFunction, which orchestrate the retry mechanism.
  • Default parameters provide 8 retry attempts with 1.25x exponential back-off, starting at 0.015s and capped at 0.25s.
  • The retry loop validates JSON against Pydantic models; on failure, it pauses using _pause, invokes the remedy LLM via remedy_function, and re-validates.
  • Graceful degradation is controlled by the graceful boolean flag, which determines whether to raise TypeValidationError or return silently after exhaustion.
  • Customization occurs through the remedy_retry_params argument, accepting tries, delay, backoff, jitter, max_delay, and graceful keys.

Frequently Asked Questions

How many retry attempts does the @contract decorator perform by default?

By default, the decorator performs 8 retry attempts plus the initial validation attempt, totaling 9 tries. This is controlled by the "tries": 8 default in _default_retry_params located at lines 37-44 of symai/strategy.py. You can override this by passing a custom remedy_retry_params dictionary with your desired "tries" value.

What happens to the delay between retry attempts?

The delay grows exponentially according to the formula delay * (backoff ** attempt), clamped at max_delay. The _pause method at lines 28-36 implements this calculation and adds optional random jitter. For example, with delay=0.1 and backoff=2.0, the second retry waits 0.2 seconds, the third waits 0.4 seconds, and so on, up to the specified ceiling.

Can the @contract decorator silently ignore validation failures?

Yes. Setting "graceful": True in the remedy_retry_params dictionary instructs the _handle_validation_failure method to return None instead of raising TypeValidationError when all retries exhaust. This is useful for applications that must continue execution even when LLM outputs fail schema validation, though it requires careful handling of None returns in downstream logic.

Does the remedy LLM use the same engine as the primary forward call?

The remedy LLM uses the currently registered engine through the remedy_function instance, which is a standard Function object from symai/components.py. It inherits the active backend configuration from symai/backend/settings.py. While it typically uses the same engine as the primary forward method, the decorator clears and re-adapts the function context specifically for the remedy prompt, ensuring the correction request is handled independently of the original conversation state.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →