Interception
Validate, redact, redirect, or reject data before it reaches the queue.
Validate, redact, redirect, or reject data before it reaches the queue.
Interception hooks into the producer side of enqueue — before anything is
serialized and written to storage. Use it to validate inputs, redact
secrets, or reshape what actually gets enqueued, in one place instead of at
every call site. Without it, a bad or unserializable value either raises at
enqueue time in a way that's hard to centralize, or slips through and fails
later on the worker.
Each SDK hooks into this at a different point:
This SDK classifies every argument passed to .delay() or
.apply_async() by its Python type, automatically, against a built-in and
user-extensible registry. See
Classifying arguments below.
This SDK runs enqueue interceptors — functions you register with
queue.intercept() that inspect the whole call (task name + args) and
decide its fate. See Enqueue interceptors below.
This SDK runs enqueue interceptors — functions you register with
flexiq.intercept() that inspect the whole call (task name + payload) and
decide its fate. See The interceptor pipeline
below.
This SDK hooks into it nowhere. There is no intercept, no registry, and no
producer-side hook of any kind — enqueue takes a TaskCall<T> and writes it.
Most of what interception is for does not arise, because the call is typed:
<task>::call(..) mirrors the function's own signature, so a value the task
does not accept is a compile error rather than something to catch at the
producer. What is left — validating a value the type system allows, or
rejecting one — is a function you call before call(..). See
Validating before the call below.
Across the SDKs, an interceptor resolves to one of a handful of named strategies:
| Strategy | Outcome |
|---|---|
PASS | No change — the original value is used as-is. |
CONVERT | The original value is replaced with a substitute. |
REDIRECT | The original value isn't used directly — something else happens instead. |
REJECT | The enqueue is blocked and an error is raised. |
These strategy names are shared vocabulary, not a shared mechanism.
Here, REDIRECT swaps a single non-serializable argument for a
dependency-injection marker — the task name and the rest of the call are
untouched. There's also a fifth strategy, PROXY, that doesn't exist in the
other SDKs. See Classifying arguments below.
Here, REDIRECT replaces the entire enqueue call with a different task
and different args. There's no argument-level classification and no
PROXY strategy. See Enqueue interceptors below.
Here, REDIRECT replaces the entire enqueue call with a different task
and different payload. There's no argument-level classification and no
PROXY strategy. See The interceptor pipeline
below.
None of these four exist here — not as an enum, not as a mode, not as a return value. Read the table as vocabulary for the other runtimes, and map each row to what actually does the job:
| Strategy | Rust equivalent |
|---|---|
PASS | The default and the only behaviour. |
CONVERT | A Serialize impl, or a type conversion at the call site. |
REDIRECT | Call a different task's call(..). It is your if, not the queue's. |
REJECT | Return an error before enqueue, or let the encoder refuse the value. |
There is no PROXY either, and nothing to build one on: a task takes its
declared arguments and nothing is injected, so there is no seam where a live
object could be swapped for a recipe.
Enable interception on the Queue constructor:
queue = Queue(db_path="tasks.db", interception="strict")Without interception, values are passed directly to the serializer. A SQLAlchemy session or file handle would either raise a serialization error at enqueue time or produce a broken payload that fails on the worker.
| Mode | Behavior |
|---|---|
"off" | Disabled (default). All arguments pass through to the serializer unchanged. |
"strict" | Raises InterceptionError immediately when a rejected type is detected. |
"lenient" | Logs a warning and drops the rejected argument instead of raising. |
"strict" is recommended for production — it surfaces problems at call
time rather than causing silent task failures.
Every argument gets one of five strategies:
| Strategy | What happens | Examples |
|---|---|---|
PASS | Sent as-is to the serializer | int, str, bool, bytes |
CONVERT | Transformed to a serializable form, reconstructed on the worker | UUID, datetime, Decimal, Path, Enum, Pydantic models, dataclasses |
REDIRECT | Replaced with a DI marker; the worker injects the named resource | SQLAlchemy sessions, Redis clients, MongoDB clients |
PROXY | Deconstructed to a recipe; reconstructed as a live object on the worker | File handles, loggers, requests.Session, httpx.Client, boto3 clients |
REJECT | Raises InterceptionError in strict mode, dropped in lenient mode | Thread locks, generators, coroutines, sockets |
These are converted automatically when interception is enabled:
| Type | Notes |
|---|---|
uuid.UUID | Stored as "uuid:<hex>" |
datetime.datetime / date / time / timedelta | ISO format |
decimal.Decimal | Stored as string to preserve precision |
pathlib.Path / PurePath | Stored as POSIX string |
re.Pattern | Pattern string + flags |
collections.OrderedDict | Preserves insertion order |
pydantic.BaseModel | Via .model_dump() (if pydantic is installed) |
enum.Enum subclasses | Class path + value |
| Dataclasses | Auto-detected via dataclasses.is_dataclass() |
NamedTuple subclasses | Auto-detected |
These connectors are automatically detected and replaced with a resource injection marker. The worker injects the named resource instead of attempting to deserialize a live connection object:
| Type | Default resource name |
|---|---|
sqlalchemy.orm.Session | "db" |
sqlalchemy.ext.asyncio.AsyncSession | "db" |
sqlalchemy.engine.Engine | "db" |
sqlalchemy.ext.asyncio.AsyncEngine | "db" |
redis.Redis | "redis" |
redis.asyncio.Redis | "redis" |
pymongo.MongoClient | "mongo" |
motor.motor_asyncio.AsyncIOMotorClient | "mongo" |
psycopg2.extensions.connection | "db" |
asyncpg.connection.Connection | "db" |
django.db.backends.base.base.BaseDatabaseWrapper | "db" |
aiohttp.ClientSession | "aiohttp_session" |
The resource name is the key you use in @queue.worker_resource("name").
If your resource has a different name, register a custom redirect with
register_type().
REDIRECT is a type shift, not just a value swap: the caller passes a live
Session (or Redis, MongoClient, ...) to .delay(), but the worker's
task function receives whatever the named resource's factory returns —
for "db", that's typically a sessionmaker you call to get a session, a
different type than what the caller passed in. See
Dependency
Injection for how the factory return value is injected.
These objects are deconstructed to a recipe dict and rebuilt by the worker:
| Type | Handler name |
|---|---|
io.TextIOWrapper, io.BufferedReader, io.BufferedWriter, io.FileIO | "file" |
logging.Logger | "logger" |
requests.Session | "requests_session" |
httpx.Client / httpx.AsyncClient | "httpx_client" |
boto3 clients (via botocore.client.BaseClient) | "boto3_client" |
google.cloud.storage.Client / Bucket / Blob | "gcs_client" |
See Resource Proxies for security options and handler details.
These are always rejected because they cannot cross process or serialization boundaries:
Lock, RLock, Semaphore, Event)socket.socketsubprocess.Popenasyncio.Task / asyncio.Futurecontextvars.ContextLock and QueueEach rejection includes a message explaining why and suggests alternatives.
Add custom rules for types not covered by the built-ins:
from myapp import MyDBClient, MoneyAmount, APIConnection
# Treat a custom DB client as a worker resource (worker must have "my_db" registered)
queue.register_type(MyDBClient, "redirect", resource="my_db")
# Convert a custom value type to something serializable
queue.register_type(
MoneyAmount,
"convert",
converter=lambda m: {"__type__": "money", "value": str(m.value), "currency": m.currency},
type_key="money",
)
# Reject with a helpful message
queue.register_type(
APIConnection,
"reject",
message="API connections are process-local. Register it as a worker resource instead.",
)register_type() requires interception to be enabled ("strict" or
"lenient"). Calling it when interception is "off" raises RuntimeError.
| Parameter | Description |
|---|---|
python_type | The type to register. |
strategy | "pass", "convert", "redirect", "reject", or "proxy". |
resource | Resource name for "redirect". |
message | Rejection reason for "reject". |
converter | Converter callable for "convert". |
type_key | Dispatch key for the converter reconstructor. |
proxy_handler | Handler name for "proxy". |
| Parameter | Default | Description |
|---|---|---|
interception | "off" | Interception mode: "strict", "lenient", or "off". |
max_intercept_depth | 10 | Maximum depth the walker recurses into nested containers. |
Inspect how interception would classify arguments without actually transforming them:
from myapp.tasks import queue
report = queue.analyze_arguments(
args=(user_session, "Hello"),
kwargs={"attachment": open("file.pdf", "rb")},
)
print(report)
# Argument Analysis:
# args[0] (Session) → REDIRECT (redirect to worker resource 'db')
# args[1] (str) → PASS
# kwargs.attachment (BufferedReader) → PROXY (handler=file)analyze_arguments() is a development and debugging tool. It reads the
registry but makes no changes to arguments, and returns an empty report when
the queue was created with interception="off". The per-task variant
my_task.analyze(...) takes the task's own call arguments directly.
stats = queue.interception_stats()
# {
# "total_intercepts": 1200,
# "total_duration_ms": 216.0,
# "avg_duration_ms": 0.18,
# "strategy_counts": {"pass": 800, "convert": 250, "redirect": 100, "proxy": 50, "reject": 0},
# "max_depth_reached": 3,
# }total_intercepts always equals the sum of strategy_counts.
See Observability for Prometheus metrics and dashboard endpoints.
queue.intercept(interceptor) registers a function that runs at the very
start of every enqueue — before per-task defaults, middleware, and gates —
and decides what happens to the call:
import { Interception } from "@byteveda/flexiq";
queue.intercept((taskName, args) => {
if (taskName === "chargeCard" && (args[0] as number) < 0) {
return Interception.reject("charge amount must be non-negative");
}
return Interception.pass();
});An interceptor returns one of four outcomes:
| Outcome | Effect |
|---|---|
Interception.pass() | Enqueue unchanged. |
Interception.convert(args) | Replace the args; the task name stays the same. |
Interception.redirect(taskName, args) | Enqueue a different task with new args instead. |
Interception.reject(reason) | Block the enqueue — enqueue/enqueueMany throws InterceptionError. |
Rejecting aborts the enqueue — the job is never created and the error propagates to the caller:
queue.enqueue("charge", [-5]); // throws — nothing is enqueuedMultiple interceptors chain in registration order, each seeing the previous
one's (possibly redirected) task name and args. redirect is rejected for
enqueueMany (a batch is stored under one task name) and for tasks
registered with per-task codecs —
the redirect target's codec chain can't be resolved from a bare name.
onEnqueuequeue.intercept() is distinct from the onEnqueue
middleware hook:
intercept runs first and can redirect to a different task or reject
outright; onEnqueue runs after and mutates the (possibly redirected)
context in place — use it to rewrite enqueue options (metadata, priority,
and so on) rather than the payload. Most apps need only one.
Some SDKs also ship object proxies — a system for shipping non-serializable objects (file handles, sessions) through the queue. Node doesn't need it: a handler closes over its own resources, or pulls them from dependency injection. Keep non-serializable state on the worker and pass plain data through the queue.
flexiq.intercept(interceptor) registers an Interceptor — a functional
interface that sees the task name and payload and returns one of four
Interception strategies:
flexiq.intercept((taskName, payload) -> {
if (payload instanceof String s && s.startsWith("pw:")) {
return Interception.convert("***"); // redact before it reaches storage
}
return Interception.pass();
});| Strategy | Effect |
|---|---|
Interception.pass() | Enqueue the payload unchanged. |
Interception.convert(payload) | Replace the payload (e.g. with a proxy ref). |
Interception.redirect(taskName, payload) | Enqueue a different task (and payload) instead. |
Interception.reject(reason) | Block the enqueue — InterceptionException is thrown and no job is created. |
Interceptors run synchronously in registration order; each sees the previous
one's result. A Redirect retargets the rest of the pipeline — later
interceptors, middleware, and gates all see the new task name and payload.
Rejecting from an interceptor aborts the enqueue — the job is never created and the error propagates to the caller:
flexiq.intercept((taskName, payload) -> {
if (taskName.equals("charge") && ((Order) payload).total() < 0) {
return Interception.reject("charge amount must be non-negative");
}
return Interception.pass();
});
flexiq.enqueue(CHARGE, invalidOrder); // throws InterceptionException — nothing enqueuedonEnqueue hookInterceptors decide the payload's fate; to rewrite enqueue options or
attach metadata, use middleware
onEnqueue. It receives a mutable EnqueueContext after interceptors have
run — every enqueue runs interceptors → onEnqueue middleware → enqueue
gates, then serializes, codec-encodes, and submits. Gates see the payload
that will actually be stored, after any rewrites.
enqueueMany runs each payload through the interceptors, so
a batch can't bypass the contract. Convert rewrites the item; Reject
fails the whole batch; Redirect is unsupported in a batch (it would move
an item out of the single-task batch) and throws.null is a bug and throws
InterceptionException.Keep interceptors fast and side-effect-free — they run on the producer
thread inside every enqueue call.
With no hook to register, the place to centralise a producer-side rule is a
function that builds the call. Give it the validation and hand back a
TaskCall, and every call site goes through it because there is nothing else to
call:
use flexiq::TaskCall;
/// The only sanctioned way to enqueue a charge.
fn charge_call(amount_cents: i64) -> Result<TaskCall<charge_card>, ChargeError> {
if amount_cents < 0 {
return Err(ChargeError::Negative);
}
Ok(charge_card::call(amount_cents))
}
queue.enqueue(charge_call(-5)?)?; // never reaches the queueRedirecting is the same idea with a second arm — but note that the return type
pins the task, so two task types need two functions or an enum. TaskCall<T> is
generic in T, and charge_card and charge_card_v2 are different types.
Some values pass the type checker and still have no representation in the call
envelope. Those are caught at enqueue, not at call(..), and come back as a
QueueError:
task `charge_card` could not encode its arguments: cannot encode task argument:
a u64 out of range for the envelope's signed integerThe refusals are structural rather than configurable, because a value that
encoded differently would still interoperate and quietly break auto:
idempotency keys:
| Refused | Why |
|---|---|
A u64, i128 or u128 outside i64's range | Nothing widens silently, and wrapping would change the value |
| A map key that is not text | The envelope's maps are text-keyed |
The second one catches more than it sounds like. Only three things make a legal
key: a &str/String, a char, and a fieldless enum variant — which travels as
its own name. A bool, any integer, a float, bytes, None, (), and every
composite are refused, so a HashMap<i64, _> argument fails at enqueue even
though it serializes fine everywhere else. Key by String instead.
Everything else encodes: f32/f64, byte strings, Option, Vec, structs, and
both fieldless and data-carrying enum variants (the latter as serde's externally
tagged one-entry map). The serializer runs in binary mode —
is_human_readable() is false — so a Uuid round-trips as its bytes rather
than its hyphenated string.
There is no hook that could redact for you, so keep the secret out of the payload instead of trying to strip it later. Enqueue a key and let the task read the value from the store it came from:
// Not: charge_card::call(card_number)
queue.enqueue(charge_card::call(vault_token))?;That is worth doing regardless of the runtime — a payload is at rest in storage, readable by anything with the connection string.
If an argument is a struct, its fields encode in declaration order and
nothing sorts them. Reordering the fields changes the encoded bytes, which
changes the auto: key .idempotent() derives — so a field moved during a
refactor silently stops deduping against jobs enqueued by the old build.