Skip to content
RelPrim docsInvalid response recovery

Examples

Example: invalid response recovery

A complete, runnable example of result validation. A provider returns a successful response with unusable data, RelPrim rejects it and retries, and the report records why.

A successful call is not the same as a usable result. This example uses validation to treat a well-formed but unusable response as a failed attempt, so the normal retry policy can recover from it.

The problem

A checkout page asks a pricing service for a quote. The service answers with HTTP 200, but sometimes the body has no price. Nothing raises an exception, so without validation the bad quote flows straight into the order. Checking it by hand after the call means retry logic has to be written again for this one failure.

Implementation

The validator is a plain function, so the example has no dependencies. The demo provider returns a quote without a price on its first call.

import asyncio

from relprim import (
    RetryPolicy,
    ValidationFailedError,
    async_operation,
    validation_policy,
    validator,
)


class DemoQuoteProvider:
    """Answers with HTTP 200 every time, but the first body is unusable."""

    def __init__(self) -> None:
        self.calls = 0

    async def quote(self, sku: str) -> dict[str, object]:
        self.calls += 1
        if self.calls == 1:
            return {"sku": sku, "price_cents": None}
        return {"sku": sku, "price_cents": 1299}


def has_positive_price(quote: dict[str, object]) -> bool:
    price = quote.get("price_cents")
    return isinstance(price, int) and price > 0


async def no_sleep(delay: float) -> None:
    """Skip the backoff delay so the example runs instantly."""


async def main() -> None:
    provider = DemoQuoteProvider()

    result = await (
        async_operation("fetch_quote", provider.quote)
        .with_validation(
            validation_policy(
                validator(
                    "positive_price",
                    has_positive_price,
                    message="Quote must contain a positive price_cents.",
                )
            )
        )
        .with_retry(
            RetryPolicy(
                max_attempts=3,
                retry_on=(ValidationFailedError,),
                async_sleeper=no_sleep,
            )
        )
        .run("SKU-42")
    )

    report = result.report
    print(result.value)
    print(f"attempt_count={report.attempt_count} retry_count={report.retry_count}")
    first_error = report.attempts[0].error
    if first_error is not None:
        print(f"attempt 1: {first_error.type}: {first_error.message}")
    print(f"final validation valid: {report.metadata['validation_valid']}")


asyncio.run(main())

It prints:

{'sku': 'SKU-42', 'price_cents': 1299}
attempt_count=2 retry_count=1
attempt 1: ValidationFailedError: Validation failed in 'positive_price': Quote must contain a positive price_cents.
final validation valid: True

What happens

  1. Attempt 1 returns {"price_cents": None}. The call itself succeeded, but the positive_price validator returns False, so the attempt fails with ValidationFailedError. Its message names the validator and the reason.
  2. ValidationFailedError matches retry_on, so the attempt is retried. The default retry_on=(Exception,) would match it too; once you narrow retry_on, list it explicitly as here.
  3. Attempt 2 returns a priced quote. It passes validation and becomes result.value.

Validation runs after each attempt, before the retry decision, and the value is returned unchanged: a validator accepts or rejects, it does not transform. The report metadata describes the final validation: validation_performed, validation_valid, validation_validator_name and validation_reason.

Why this matters

“Transport success” and “valid business result” become two separate checks with one recovery path. The code that uses the quote never sees the invalid one, and the report says which validator rejected which attempt.

Production note

Only retry a validation failure when a new attempt can plausibly return something different, as with flaky upstream data or generated content. A validation failure caused by a bad request will fail every time; leave ValidationFailedError out of retry_on there, or add a fallback, whose candidates are validated the same way.

If the response already has a schema or a model, use it instead of a hand-written check:

Both plug into validation_policy(...) exactly like the validator above.