Interception
Interception lets you insert callbacks before, after, or around any method call, constructor invocation, or field access at runtime - without changing code or recompiling.
Scope: PAL interception is networked AOP — callbacks run in a separate peer (potentially on a different machine) and are registered at runtime through the directory, not woven into the target at compile time. The shape suits cross-cutting concerns that would otherwise need a service-mesh sidecar — telemetry, authorization, rate limiting, fault injection, A/B routing, hot-patching — but applies them at the operation level (method calls, constructor invocations, and field reads/writes) rather than the network-request level. It is not a drop-in replacement for compile-time aspects when you want in-process structural cross-cuts. For the constraints the model imposes (the target must be woven; matching is pattern-based, not type-hierarchy-based) see the Limitations section.
What is Interception?
Imagine you want to know every time a method is called, a constructor invoked, or a field read or written in a running application:
- To verify it's called with correct arguments
- To measure how long it takes
- To check application state before execution
- To mock the return value for testing
PAL's interception system lets you register these callbacks dynamically while the application runs.
How Interception Works
- Your application's
.classfiles are woven with AspectJ at build time (post-compile). - You register an intercept pattern in the directory.
- PAL matches method calls, constructor invocations, and field accesses against the pattern.
- When matched, PAL sends a callback message to your peer.
- Your peer receives the callback and can inspect/modify behavior.
Key: You don't modify the target code. You register intercepts from outside.
Enabling Interception
Application Must Be Woven
Your application's .class files must be woven with AspectJ for interception to work. Weaving is a build-time step that runs post-compile — the AspectJ Gradle/Maven plugin transforms compiled .class files in place against the pal-weave aspect library. pal run does not weave; it provides the matching runtime aspect classpath the woven code needs.
To configure weaving in your build.gradle:
configurations {
aspectjTools
aspect
}
dependencies {
aspectjTools 'org.aspectj:aspectjtools:1.9.24'
aspect 'io.quasient.pal:pal-weave:${pal.version}'
implementation 'org.aspectj:aspectjrt:1.9.24'
}
tasks.register('weaveClasses', JavaExec) {
dependsOn classes
mainClass = 'org.aspectj.tools.ajc.Main'
classpath = configurations.aspectjTools
args = [
'-inpath', sourceSets.main.output.classesDirs.asPath,
'-aspectpath', configurations.aspect.asPath,
'-d', sourceSets.main.java.destinationDirectory.get().asFile.path,
'-classpath', sourceSets.main.compileClasspath.asPath,
]
}
tasks.named('jar') { dependsOn weaveClasses }
Maven equivalent (pom.xml fragments)
<dependencies>
<dependency>
<groupId>io.quasient.pal</groupId>
<artifactId>pal-weave</artifactId>
<version>${pal.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<version>1.15.0</version>
<configuration>
<complianceLevel>17</complianceLevel>
<source>17</source>
<target>17</target>
<aspectLibraries>
<aspectLibrary>
<groupId>io.quasient.pal</groupId>
<artifactId>pal-weave</artifactId>
</aspectLibrary>
</aspectLibraries>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Peer Must Be Interceptable
Start peer with interception enabled:
The --interceptable flag enables the intercept matcher service.
Intercept Types
The examples in this section all use InterceptableMethodCall. The same intercept types apply to constructors and field reads/writes:
- Constructors — also wrapped in
InterceptableMethodCall, but with"new"as the name (PAL's convention for constructor matching). For example,new InterceptableMethodCall("new", List.of("java.lang.Integer"))matches a single-Integer constructor on the configured class. - Fields — substitute
InterceptableFieldOp("balance", FieldOpType.GET)(orFieldOpType.SETfor writes) in theInterceptRequest. BEFORE fires before the read/write, AFTER after, and AROUND can substitute the read value or block the write.
BEFORE
Callback executes before the method, synchronously:
InterceptRequest<InterceptableMethodCall> intercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.Calculator",
"com.example.CalculatorCallback",
"handle",
new InterceptableMethodCall("add", Collections.emptyList()));
Use cases:
- Verify method is called with expected arguments
- Check preconditions
- Log entry to method
- Authorization checks
Timing: Blocks target method until callback completes (synchronous). Use BEFORE_ASYNC for fire-and-forget callbacks (e.g., logging method entry without blocking execution).
AFTER
Callback executes after the method completes, synchronously:
InterceptRequest<InterceptableMethodCall> intercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.AFTER,
"com.example.Calculator",
"com.example.CalculatorCallback",
"handle",
new InterceptableMethodCall("add", Collections.emptyList()));
Use cases:
- Verify return value
- Override return value
- Log method exit
- Collect metrics
Timing: Blocks until callback completes (synchronous). Use AFTER_ASYNC for fire-and-forget callbacks.
AROUND
Callback wraps the method execution with before/after logic. Call ctx.proceed() to execute the method (or next layer in the chain), or skip execution entirely:
InterceptRequest<InterceptableMethodCall> intercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.AROUND,
"com.example.Calculator",
"com.example.CalculatorCallback",
"handle",
new InterceptableMethodCall("add", Collections.emptyList()));
Use cases:
- Mock return values in tests
- Cache results (skip expensive computation)
- Transform arguments before and return values after execution
- Circuit breaker pattern
Timing: The callback decides whether to call ctx.proceed() (execute the method or next AROUND layer) or to return InterceptCallbackResponse.skipProceed() to short-circuit with a value set via ctx.setReturnValue(...). See Processing Callbacks for the full method table.
BEFORE_ASYNC and AFTER_ASYNC
Fire-and-forget callbacks that don't block execution:
InterceptRequest<InterceptableMethodCall> intercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE_ASYNC, // or AFTER_ASYNC
"com.example.Service",
"com.example.ServiceCallback",
"handle",
new InterceptableMethodCall("process", Collections.emptyList()));
Use cases:
- Telemetry and monitoring
- Audit logging
- Metrics collection
- Notifications
Timing: Callback is sent but caller doesn't wait for response.
Limitations: Cannot modify arguments, return values, or throw exceptions (fire-and-forget).
Ordering: Async callbacks for two different intercepted operations can arrive at the callback peer in either order — even if the operations themselves ran in a strict sequence. Correlate received callbacks by content (e.g. message id, intercepted argument), never by arrival index. If you need a total order across operations, use a synchronous type (BEFORE, AFTER, or AROUND).
Choosing Synchronous vs Asynchronous
As a general rule, the choice between synchronous and asynchronous intercept types maps to whether the callback needs to influence execution or merely observe it:
-
Asynchronous (
BEFORE_ASYNC,AFTER_ASYNC) — use for observability and data collection: logging, tracing, monitoring, metrics, audit trails, big data pipelines, AI/ML feature capture, and cybernetics (feedback loops that don't gate the current call). -
Synchronous (
BEFORE,AFTER,AROUND) — use for control and correctness: hot-patching, access control, management policies, consensus enforcement, transaction coordination, input validation, and any case where the callback must block, modify, or reject the operation.
When in doubt, start with asynchronous — it has zero impact on the target method's latency and cannot break application behavior. Move to synchronous only when you need the callback's result before the operation can proceed.
Registering Intercepts
From Java Code
// 1. Connect to directory
PalDirectory directory = new PalDirectory("localhost:2379");
// 2. Create intercept request
InterceptRequest<InterceptableMethodCall> intercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.Service",
"com.example.ServiceCallback",
"handleProcessRequest",
new InterceptableMethodCall("processRequest", Collections.emptyList()));
// 3. Register
directory.createIntercept(intercept);
// 4. Callbacks will now be sent to your peer
Pattern Matching
Class and method patterns are ant-style: * matches a single segment, ** matches multiple segments. Parameter types are matched literally (no wildcards) and must be fully qualified (java.lang.String, not String); primitives use their keyword names (int, long, etc.). Omitting parameter types (empty list) matches all overloads.
| Class pattern | Member name pattern | Parameter types | Matches |
|---|---|---|---|
com.example.Calculator |
add |
(empty) | Every overload of Calculator.add |
com.example.Calculator |
add |
["int", "int"] |
Only Calculator.add(int, int) |
com.example.* |
process* |
(empty) | Every class directly in com.example, every method whose name starts with process |
com.example.**.* |
* |
(empty) | Every class in com.example and any subpackage, every method |
The same pattern syntax applies to both methods and fields — the Interceptable subtype passed to InterceptRequest (InterceptableMethodCall vs InterceptableFieldOp) tells PAL which kind of operation to match.
In code, the class pattern is the fourth argument to InterceptRequest, and the member name pattern with its parameter types is wrapped in InterceptableMethodCall:
new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.Calculator", // class pattern
"com.example.CalculatorCallback",
"handle",
new InterceptableMethodCall("add", // method pattern
Arrays.asList("int", "int"))); // parameter types
Receiving Callbacks
Setup Callback Peer
// Callback methods must be public static, accept InterceptContext, return InterceptCallbackResponse
public class CalculatorCallback {
public static InterceptCallbackResponse handle(InterceptContext ctx) {
System.out.println("Method called: " + ctx.getArgs());
return new InterceptCallbackResponse();
}
}
Run the callback peer:
pal run -d localhost:2379 --zmq-rpc auto --rpc-default-action ALLOW \
-n callback-peer \
-cp callback.jar com.example.CallbackPeer
Two flags are essential:
--zmq-rpc auto— Intercept callbacks are delivered as ZMQ-RPC messages, so the callback peer must expose a ZMQ-RPC endpoint.autobinds to a free port; you can also pass a specific port number.--rpc-default-action ALLOW— The intercepted peer must be permitted to invoke the callback class on this peer.ALLOWis convenient for development. For production, replace it with a policy file (--rpc-policy <file>) that explicitly admits the intercepting peer to the callback's class and method. See RPC Policy for the full policy model.
Processing Callbacks
When a matched operation runs, the callback receives an InterceptContext. The methods available depend on the intercept type:
| Method | Purpose | BEFORE | AFTER | AROUND | *_ASYNC |
|---|---|---|---|---|---|
getArgs() |
Read the call's arguments | ✓ | ✓ | ✓ | ✓ |
setArg(i, v) |
Mutate an argument | ✓ | — | ✓ (before proceed) |
— |
getReturnValue() |
Read the method's return value | — | ✓ | ✓ (after proceed) |
— |
setReturnValue(v) |
Override the return value | — | ✓ | ✓ | — |
setExceptionToThrow(t) |
Cause an exception to propagate | ✓ | ✓ | ✓ | — |
proceed() |
Execute the method (or next AROUND layer); returns ProceedResult |
— | — | ✓ | — |
getLocalMetadata() |
Inspect the intercepted operation (class, member name, etc.) | ✓ | ✓ | ✓ | ✓ |
The context is phase-aware: calling an unsupported method (e.g., getReturnValue() from a BEFORE callback) throws InterceptTypeNotSupportedException. Within an AROUND callback, calling setArg() after proceed() throws InterceptPhaseViolationException.
The callback's return value tells PAL how to continue:
new InterceptCallbackResponse()— proceed normally (execute the method or next AROUND layer).InterceptCallbackResponse.skipProceed()— AROUND only; skip the method (and any inner AROUND layers) and return whatever was set viasetReturnValue().
See Writing Callback Handlers for the full API.
Common Use Cases
Testing: Verify Method Calls
startApplicationPeer() and startCallbackPeer() below stand in for your test harness — typically methods on a shared base class that launch pal run subprocesses and return their UUIDs.
@Test
public void serviceIsCalledWithExpectedArgs() {
UUID appPeer = startApplicationPeer();
UUID callbackPeer = startCallbackPeer();
directory.createIntercept(new InterceptRequest<>(
UUID.randomUUID(), callbackPeer, InterceptType.BEFORE,
"com.example.Service", "com.example.ServiceCallback", "handle",
new InterceptableMethodCall("processRequest", Collections.emptyList())));
app.doSomething();
// ServiceCallback.handle stores each invocation's args for assertion
assertEquals(1, ServiceCallback.getCalls().size());
assertArrayEquals(expectedArgs, ServiceCallback.getCalls().get(0));
}
Monitoring: Track Performance
A pair of BEFORE / AFTER intercepts on the same callback peer measures wall-clock latency for every method on Service:
// Pair: record start in BEFORE, log elapsed in AFTER. Same callback class.
directory.createIntercept(new InterceptRequest<>(
UUID.randomUUID(), monitorPeer, InterceptType.BEFORE,
"com.example.Service", "com.example.MonitorCallback", "onBefore",
new InterceptableMethodCall("*", Collections.emptyList())));
directory.createIntercept(new InterceptRequest<>(
UUID.randomUUID(), monitorPeer, InterceptType.AFTER,
"com.example.Service", "com.example.MonitorCallback", "onAfter",
new InterceptableMethodCall("*", Collections.emptyList())));
public class MonitorCallback {
private static final Map<String, Long> starts = new ConcurrentHashMap<>();
public static InterceptCallbackResponse onBefore(InterceptContext ctx) {
starts.put(Thread.currentThread().getName(), System.nanoTime());
return new InterceptCallbackResponse();
}
public static InterceptCallbackResponse onAfter(InterceptContext ctx) {
Long t0 = starts.remove(Thread.currentThread().getName());
if (t0 != null) {
log.info("{} took {} ms",
ctx.getLocalMetadata().methodName(),
(System.nanoTime() - t0) / 1_000_000);
}
return new InterceptCallbackResponse();
}
}
Debugging: Audit Trail
Use BEFORE_ASYNC so audit logging never blocks the audited code:
directory.createIntercept(new InterceptRequest<>(
UUID.randomUUID(), auditPeer, InterceptType.BEFORE_ASYNC,
"com.example.**.*", "com.example.AuditCallback", "log",
new InterceptableMethodCall("*", Collections.emptyList())));
public class AuditCallback {
public static InterceptCallbackResponse log(InterceptContext ctx) {
logger.info("{} called with {}",
ctx.getLocalMetadata().methodName(),
Arrays.toString(ctx.getArgs()));
return new InterceptCallbackResponse();
}
}
Testing: Mock Return Values
AROUND with skipProceed() substitutes a value without invoking the real method:
directory.createIntercept(new InterceptRequest<>(
UUID.randomUUID(), mockPeer, InterceptType.AROUND,
"com.example.DatabaseService", "com.example.MockCallback", "stub",
new InterceptableMethodCall("queryDatabase", Collections.emptyList())));
public class MockCallback {
public static InterceptCallbackResponse stub(InterceptContext ctx) {
ctx.setReturnValue(mockData);
return InterceptCallbackResponse.skipProceed();
}
}
Multiple Intercepts and Ordering
When multiple intercepts match the same operation, they execute in a specific order determined by three factors:
Execution Order
- Local vs Remote: Local intercepts (callback peer = intercepted peer) always execute before remote intercepts.
- Priority: Within each local/remote group, intercepts with lower priority values execute first (default priority is
0). - Registration order: Intercepts with the same priority execute in the order they were registered (tie-breaker).
BEFORE phase:
1. Local BEFORE callbacks (sorted by ascending priority)
2. Remote BEFORE callbacks (sorted by ascending priority)
AROUND phase (onion model):
3. Local AROUND callbacks (lower priority = outermost layer)
4. Remote AROUND callbacks (lower priority = outermost layer)
[Method Execution - innermost]
(Return values propagate outward)
AFTER phase:
5. Local AFTER callbacks (sorted by ascending priority)
6. Remote AFTER callbacks (sorted by ascending priority)
Setting Priority
Pass the priority parameter when creating an InterceptRequest. Lower values execute first. The default is 0.
// Security check: runs first (low priority value)
InterceptRequest<InterceptableMethodCall> securityIntercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.Service",
"com.example.SecurityCallback",
"handle",
new InterceptableMethodCall("*", Collections.emptyList()),
false, null, null,
-100); // priority: runs before default intercepts
// Application logic: runs at default priority
InterceptRequest<InterceptableMethodCall> appIntercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.Service",
"com.example.AppCallback",
"handle",
new InterceptableMethodCall("*", Collections.emptyList()));
// priority defaults to 0
// Logging: runs last (high priority value)
InterceptRequest<InterceptableMethodCall> loggingIntercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.Service",
"com.example.LoggingCallback",
"handle",
new InterceptableMethodCall("*", Collections.emptyList()),
false, null, null,
100); // priority: runs after default intercepts
Execution order: Security (p=-100) → Application (p=0) → Logging (p=100)
Recommended Priority Ranges
Use widely-spaced values to leave room for inserting new intercepts between existing ones:
| Range | Suggested Use |
|---|---|
| -1000 | Infrastructure-level (framework internals) |
| -100 | Security checks, authorization |
| 0 | Default — general-purpose intercepts |
| 100 | Logging, metrics, monitoring |
| 1000 | Audit trail, compliance recording |
Tip: For deterministic ordering, set priority explicitly rather than relying on registration order. Registration order depends on the timing of etcd events, which may vary.
AROUND Chain and Priority
For AROUND intercepts, priority determines the layer in the onion model:
- Lower priority = outermost layer (BEFORE logic runs first, AFTER logic runs last)
- Higher priority = innermost layer (closest to the actual method)
┌─ AROUND priority=-100 (outermost) ──────────────────┐
│ BEFORE logic runs FIRST │
│ ctx.proceed() ────────────────────────────────────▶│
│ ┌─ AROUND priority=0 ────────────────────────┐ │
│ │ ctx.proceed() ───────────────────────────▶│ │
│ │ ┌─ AROUND priority=100 (innermost) ──┐ │ │
│ │ │ ctx.proceed() ───────────────────────▶ [METHOD]
│ │ │ AFTER logic │ │ │
│ │ └────────────────────────────────────┘ │ │
│ │ AFTER logic │ │
│ └─────────────────────────────────────────────┘ │
│ AFTER logic runs LAST │
└─────────────────────────────────────────────────────┘
AROUND Chaining (Onion Model)
Multiple AROUND intercepts form a chain where each proceed() invokes the next layer, not the method directly:
┌─ Local AROUND #1 (outermost) ───────────────────────────┐
│ BEFORE logic │
│ ctx.proceed() ─────────────────────────────────────────┼──▶
│ ┌─ Local AROUND #2 ──────────────────────────────┐ │
│ │ BEFORE logic │ │
│ │ ctx.proceed() ────────────────────────────────┼───┼──▶
│ │ ┌─ Remote AROUND #1 (innermost) ────────┐ │ │
│ │ │ ctx.proceed() ───────────────────────┼───┼───┼──▶ [METHOD]
│ │ │ (return flows back) │ │ │
│ │ │ AFTER logic │ │ │
│ │ └───────────────────────────────────────┘ │ │
│ │ AFTER logic (can modify return) │ │
│ └────────────────────────────────────────────────┘ │
│ AFTER logic (can modify return) │
└─────────────────────────────────────────────────────────┘
Key behaviors:
- Argument mutations propagate inward: Each layer sees cumulative mutations from outer layers.
- Return values propagate outward: Each layer can modify the return value from inner layers.
- Skip affects all inner layers: When any layer calls
skipProceed(), all inner layers (including the method) are bypassed.
Argument Mutation in AROUND Chains
// Chain: Outer → Inner → Method
// Outer sets arg[0] = 10, calls proceed()
// Inner sees arg[0] = 10, sets arg[0] = 20, calls proceed()
// Method sees arg[0] = 20
Return Value Override in AROUND Chains
// Method returns 5
// Inner receives 5, returns 5 + 10 = 15
// Outer receives 15, returns 15 * 2 = 30
// Final result = 30
Intercept Activation Safety
By default, PAL waits for in-flight operations to finish before activating a new intercept. This applies to methods, constructors, and field operations. It ensures no execution sees a partially-activated intercept -- for example, a BEFORE callback fires but the method was already past that point.
How It Works
When a new intercept is registered, PAL:
- Fences the matching operations so no new calls can start.
- Waits for all currently executing matching calls to complete (drain).
- Activates the intercept once all in-flight calls finish.
- Unfences so new calls proceed with the intercept active.
This guarantees that every call either completes entirely without the intercept or executes entirely with it -- never a mix.
Tracking is per-operation-signature: when parameter types are specified, fencing add(int) does not block add(int, int) -- only the exact overload being intercepted is fenced. When parameter types are omitted (wildcard), all overloads are fenced. Similarly, constructors and field operations are tracked separately from methods, even if they share a name.
Enabling and Configuring
In-flight tracking is enabled by default. To disable it (immediate activation for all intercepts):
The drain timeout controls how long PAL waits for in-flight calls to complete. If the timeout expires, the intercept is not activated:
# Default: 5000ms. Increase for slow methods:
pal run --drain-timeout-ms 10000 -d localhost:2379 -cp app.jar
Both settings are also available as environment variables: PAL_IN_FLIGHT_TRACKING and PAL_DRAIN_TIMEOUT_MS.
Per-Intercept Override: forceImmediate
Individual intercepts can bypass the drain by setting forceImmediate to true:
InterceptRequest<InterceptableMethodCall> intercept = new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.HangingService",
"com.example.EmergencyCallback",
"handle",
new InterceptableMethodCall("blockingCall", Collections.emptyList()),
true /* forceImmediate */);
When to use: Emergency hot-patches where the target method is hanging or stuck, monitoring intercepts where strict activation safety isn't needed, or any case where you need the intercept active immediately regardless of in-flight calls.
When to Disable Globally
Disable in-flight tracking (--in-flight-tracking=false) when:
- Running in a controlled test environment where race conditions aren't a concern
- Performance is critical and you accept the small risk of partial activation
- All your intercepts are
BEFORE_ASYNCorAFTER_ASYNC(fire-and-forget)
Intercept TTL and Lease Management
Intercepts can be created with a TTL (Time-To-Live) so they automatically expire after a specified duration. This is useful for temporary intercepts that should clean up after themselves — for example, test-scoped intercepts or time-limited monitoring.
Creating Intercepts with a TTL
Pass a ttlSeconds parameter to createIntercept(). The method returns an InterceptLease handle for managing the lease:
// Create intercept with 5-minute TTL
InterceptLease lease = directory.createIntercept(intercept, 300);
When the TTL expires, etcd automatically deletes the intercept key. The peer's InterceptInformer detects the deletion via its watch, relays it to the InterceptMatcher, and the intercept is unregistered — no more callbacks will fire.
Without a TTL (or with ttlSeconds = 0), the intercept is attached to the owning peer's lease and lives as long as the peer is running:
// No dedicated TTL — lives as long as the peer
InterceptLease lease = directory.createIntercept(intercept);
// lease == InterceptLease.NONE (a no-op sentinel)
Manual Lease Refresh
Call keepAlive() to send a single keep-alive to etcd, extending the lease by its original TTL:
InterceptLease lease = directory.createIntercept(intercept, 30); // 30-second TTL
// ... some time later, before TTL expires ...
lease.keepAlive(); // extends by another 30 seconds
This is useful when you want explicit control over when the lease is refreshed — for example, refreshing only when a condition is met.
Auto-Refresh
For intercepts that should stay alive indefinitely (but still have a TTL as a safety net), use startAutoRefresh(). This schedules periodic keep-alive calls at ttlSeconds / 3 intervals:
InterceptLease lease = directory.createIntercept(intercept, 300); // 5-minute TTL
lease.startAutoRefresh(); // refreshes every ~100 seconds
// ... intercept stays alive until you stop it ...
lease.stopAutoRefresh(); // stop refreshing (lease will expire after TTL)
stopAutoRefresh() cancels the periodic refresh but does not revoke the lease — the intercept remains active until the remaining TTL expires.
Revoking a Lease (Immediate Removal)
Call close() to immediately revoke the lease and remove the intercept:
close() is idempotent — calling it multiple times is safe.
Resource Management with try-with-resources
InterceptLease implements AutoCloseable, so you can use try-with-resources for automatic cleanup:
try (InterceptLease lease = directory.createIntercept(intercept, 300)) {
lease.startAutoRefresh();
// ... intercept is active for the duration of this block ...
} // lease.close() called automatically — intercept removed
Behavior When TTL Expires
When a TTL expires without being refreshed:
- etcd deletes the intercept key.
- The peer's
InterceptInformerreceives a DELETE watch event. InterceptMatcherunregisters the intercept.- No further callbacks fire for the expired intercept.
- The corresponding
InterceptLeaseentry is removed fromPalDirectory.
This is the same deletion path used for manual removal — the system does not distinguish between TTL expiry and explicit deletion.
Exception Propagation
When callback handlers throw exceptions, PAL provides policies to control whether those exceptions propagate to the intercepted code.
Exception Propagation Policies
PROPAGATE_CONTROLLED_ONLY (default) — propagates exceptions only when both conditions hold: the callback completed without throwing and it explicitly called ctx.setExceptionToThrow(). Any callback crash discards the result, including any explicit exception set before the crash.
// Propagates: callback completes cleanly, explicit exception set
ctx.setExceptionToThrow(new SecurityException("Access denied"));
return new InterceptCallbackResponse();
// Does NOT propagate: callback crashed
throw new RuntimeException("Callback bug");
// Does NOT propagate: explicit exception was set, but callback then crashed
ctx.setExceptionToThrow(new ValidationException("..."));
throw new RuntimeException("Bug after setExceptionToThrow");
Use when: production systems where callback stability matters and only deliberate, cleanly-signaled exceptions should reach application code. Recommended default.
PROPAGATE_EXPLICIT_ONLY — propagates any exception that was explicitly set via ctx.setExceptionToThrow(), even if the callback subsequently crashed. Accidental crashes alone (with no explicit set) are logged but do not propagate.
// Propagates: explicit exception was set, even though callback then crashed
ctx.setExceptionToThrow(new ValidationException("..."));
throw new RuntimeException("Bug after setExceptionToThrow");
// Does NOT propagate: accidental crash with no explicit set
throw new RuntimeException("Callback bug");
Use when: explicit exceptions must always propagate, even in the face of buggy callback code that crashes after signaling.
PROPAGATE_ALL - Propagate all exceptions, including callback crashes:
// Both of these will propagate
ctx.setExceptionToThrow(new SecurityException("Access denied"));
// AND
throw new RuntimeException("Callback bug");
Use when: Testing or development where you want to catch callback bugs immediately.
SWALLOW_ALL - Never propagate exceptions (all are logged but swallowed):
// Neither of these will propagate
ctx.setExceptionToThrow(new SecurityException("Access denied"));
throw new RuntimeException("Callback bug");
Use when: Non-critical monitoring or logging where callback failures shouldn't affect application behavior.
Checked Exception Policies
Java's checked exception system requires methods to declare which checked exceptions they throw. When a callback tries to throw a checked exception not declared by the intercepted method, PAL applies a policy:
WRAP (default) - Wrap undeclared checked exceptions in RuntimeException:
// Method signature: String readFile() throws IOException
// Callback throws: SQLException (not declared)
// Result: RuntimeException wrapping SQLException
REJECT - Throw InvalidCallbackExceptionException to signal the violation:
Use when: Testing to catch incorrect exception types early.
ALLOW_ALL - Allow any exception to propagate (bypasses Java type safety):
Warning: Can violate Java's exception contract. Use only if you understand the risks.
Configuring Exception Policies
Exception policies can be configured at three levels (most specific wins):
Global Default
# Via CLI flags
pal run --exception-policy PROPAGATE_ALL \
--checked-exception-policy REJECT \
-cp app.jar
# Via environment variables
export PAL_EXCEPTION_POLICY=PROPAGATE_ALL
export PAL_CHECKED_EXCEPTION_POLICY=REJECT
# Via system properties
-Dpal.intercept.exception-policy.default=PROPAGATE_ALL
-Dpal.intercept.checked-exception-policy.default=REJECT
Per-Type Default
# Different policies for different intercept types
-Dpal.intercept.exception-policy.before=PROPAGATE_ALL
-Dpal.intercept.exception-policy.after=SWALLOW_ALL
-Dpal.intercept.exception-policy.around=PROPAGATE_CONTROLLED_ONLY
Per-Intercept Override
new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.SecuredService",
AuthCallback.class.getName(),
"handle",
new InterceptableMethodCall("*", Collections.emptyList()),
false,
ExceptionPropagationPolicy.PROPAGATE_ALL,
CheckedExceptionPolicy.REJECT);
Exception Handling Examples
Example 1: Validation with Explicit Exceptions
public class ValidationCallback {
public static InterceptCallbackResponse handle(InterceptContext ctx) {
String input = (String) ctx.getArgs()[0];
if (input == null || input.isEmpty()) {
// This will propagate with PROPAGATE_CONTROLLED_ONLY
ctx.setExceptionToThrow(new IllegalArgumentException("Input required"));
}
return new InterceptCallbackResponse();
}
}
// Register with default policy (PROPAGATE_CONTROLLED_ONLY)
new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE,
"com.example.Service",
ValidationCallback.class.getName(),
"handle",
new InterceptableMethodCall("*", Collections.emptyList()));
Example 2: Resilient Monitoring
public class MetricsCallback {
public static InterceptCallbackResponse handle(InterceptContext ctx) {
// Even if metrics system crashes, don't break application
metrics.record(ctx.getLocalMetadata().methodName(), ctx.getReturnValue());
return new InterceptCallbackResponse();
}
}
// Register with SWALLOW_ALL policy
new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.AFTER,
"com.example.Service",
MetricsCallback.class.getName(),
"handle",
new InterceptableMethodCall("*", Collections.emptyList()),
false,
ExceptionPropagationPolicy.SWALLOW_ALL,
null);
Example 3: Exception Transformation in AROUND
public class ExceptionWrapperCallback {
public static InterceptCallbackResponse handle(InterceptContext ctx) {
ProceedResult result = ctx.proceed();
if (result.hasException()) {
Throwable original = result.getThrownException();
// Wrap low-level exceptions in domain exceptions
ctx.setExceptionToThrow(
new ServiceException("Operation failed", original)
);
}
return new InterceptCallbackResponse();
}
}
API Misuse Exceptions
PAL throws specific exceptions when callback code violates the intercept API contract. These always propagate regardless of policy:
InterceptTypeNotSupportedException - Operation not supported for current intercept type:
// ERROR: Can't get return value in BEFORE intercept
public static InterceptCallbackResponse handle(InterceptContext ctx) {
Object value = ctx.getReturnValue(); // throws InterceptTypeNotSupportedException
return new InterceptCallbackResponse();
}
InterceptPhaseViolationException - Operation called during wrong phase (AROUND intercepts):
// ERROR: Can't modify arguments after proceed
public static InterceptCallbackResponse handle(InterceptContext ctx) {
ctx.proceed();
ctx.setArg(0, "too late"); // throws InterceptPhaseViolationException
return new InterceptCallbackResponse();
}
InvalidCallbackExceptionException - Callback threw checked exception not compatible with method signature (only when policy is REJECT):
// Method declares: throws IOException
// Callback throws: SQLException (not compatible)
// Result: InvalidCallbackExceptionException with details
These exceptions help you catch programming errors during development. Fix the callback code to follow the API contract.
Async Intercepts and Exceptions
BEFORE_ASYNC and AFTER_ASYNC intercepts always use SWALLOW_ALL policy:
// Fire-and-forget - exceptions logged but never propagate
new InterceptRequest<>(
UUID.randomUUID(),
callbackPeerUuid,
InterceptType.BEFORE_ASYNC,
"com.example.Service",
AsyncCallback.class.getName(),
"handle",
new InterceptableMethodCall("*", Collections.emptyList()),
false,
ExceptionPropagationPolicy.PROPAGATE_ALL, // ignored for async
null);
This is because async intercepts don't block the caller, so there's no synchronous path to propagate exceptions.
Callback Timeouts
By default, the intercepted peer waits 3000ms for a callback peer to respond to synchronous BEFORE/AFTER callbacks. This can be configured at two levels:
Global default via pal run --callback-timeout-ms <ms> (or env var PAL_CALLBACK_TIMEOUT_MS):
--callback-timeout-ms 3000— wait up to 3 seconds (default)--callback-timeout-ms 0— no timeout (infinite wait)
Per-intercept override via the callbackTimeout field in intercept bundles:
defaults:
callbackTimeout: "5s"
intercepts:
- target: "com.example.Calculator.add"
type: BEFORE
callbackTimeout: "500ms" # overrides default for this intercept
Supported duration units: ms, s, m, h, d.
Timeout resolution order: per-intercept override → bundle defaults → global peer setting.
When a callback times out, the intercepted peer logs a warning and proceeds as if the callback returned shouldProceed=true with no mutations.
Intercept Bundles
When working with multiple intercepts, defining them individually can become tedious and error-prone. Intercept bundles let you declare a group of related intercepts and manage them as a unit --- either via YAML files on the CLI, or programmatically from Java code.
What is a Bundle?
A bundle is a named collection of intercept definitions with shared defaults. It provides:
- Declarative or programmatic --- define bundles in YAML files or build them with the Java builder API
- Shared defaults --- set peer, priority, TTL, and exception policies once, override per-intercept as needed
- Lifecycle management --- apply, diff, status-check, and remove all intercepts in one operation
- Idempotency --- re-applying a bundle skips intercepts that already exist
- Metadata tracking --- PAL stores bundle metadata in etcd so you can query or remove by bundle name
Bundle YAML Format
bundle: "fraud-check-v1"
defaults:
peer: "fraud-checker"
priority: 0
ttl: 30s
intercepts:
- target: com.acme.payment.OrderService.placeOrder
type: BEFORE
callback:
class: com.acme.fraud.FraudChecker
method: verify
params:
- com.acme.payment.Order
- target: com.acme.payment.OrderService.refund
type: AROUND
callback:
class: com.acme.fraud.FraudChecker
method: wrapRefund
priority: 10
- target: com.acme.payment.OrderService.status
kind: field
fieldOp: GET
type: AFTER
callback:
class: com.acme.audit.FieldAuditor
method: onFieldRead
Each entry under intercepts uses the target field in ClassName.memberName format. The defaults section sets values inherited by all intercepts unless individually overridden.
The optional params field restricts matching to a specific method or constructor overload. When params is omitted, the intercept matches all overloads of the target method or constructor. Parameter types must be fully qualified (e.g., com.acme.payment.Order, not Order).
Bundle Commands
| Command | Description |
|---|---|
pal intercept apply <file> |
Create intercepts from a YAML bundle |
pal intercept apply --dry-run <file> |
Preview what would change without applying |
pal intercept diff <file> |
Compare bundle against current directory state |
pal intercept status -f <file> |
Check which bundle intercepts are active |
pal intercept status --bundle <name> |
Check status by stored bundle name |
pal intercept rm -f <file> |
Remove all intercepts defined in a bundle file |
pal intercept rm --bundle <name> |
Remove all intercepts by bundle name |
pal intercept rm --peer <name> |
Remove all intercepts for a peer |
Typical Workflow
# 1. Preview changes
pal intercept diff -d localhost:2379 fraud-check.yaml
# 2. Apply the bundle
pal intercept apply -d localhost:2379 fraud-check.yaml
# 3. Verify the intercepts are active
pal intercept status -d localhost:2379 -f fraud-check.yaml
# 4. When done, remove all intercepts
pal intercept rm -d localhost:2379 -f fraud-check.yaml
Idempotent Apply
Running pal intercept apply on a bundle that has already been applied is safe --- existing intercepts are detected and skipped:
This makes bundles suitable for use in scripts and CI/CD pipelines.
Bundle Metadata
When a bundle is applied, PAL stores lightweight metadata in etcd recording the bundle name, the peer UUID, and the UUIDs of the intercepts that were created. This metadata enables:
pal intercept rm --bundle <name>--- remove all intercepts by bundle name without needing the original YAML filepal intercept status --bundle <name>--- check the status of a previously applied bundle
For full CLI reference details, see the CLI Reference.
Programmatic API
Bundles can also be built and managed entirely from Java code using the builder API. This is useful when intercepts are driven by application logic rather than static YAML files.
Building and applying a bundle:
// Define bundle-level defaults (peer name, priority, TTL, etc.)
InterceptBundleDefaults defaults =
new InterceptBundleDefaults("fraud-checker", null, null, null, null, null, null);
// Build the bundle with the fluent builder API
InterceptBundleSpec bundle = InterceptBundleSpec.builder("fraud-check-v1")
.defaults(defaults)
.addIntercept(InterceptSpec.builder()
.targetClass("com.acme.OrderService")
.targetName("placeOrder")
.type(InterceptType.BEFORE)
.callbackClass("com.acme.FraudChecker")
.callbackMethod("verify")
.parameterTypes(List.of("com.acme.payment.Order")) // specific overload
.build())
.addIntercept(InterceptSpec.builder()
.targetClass("com.acme.OrderService")
.targetName("refund")
.type(InterceptType.AROUND)
.callbackClass("com.acme.FraudChecker")
.callbackMethod("wrapRefund")
.build())
.addIntercept(InterceptSpec.builder()
.targetClass("com.acme.OrderService")
.targetName("status")
.kind(InterceptableKind.FIELD)
.fieldOpType(FieldOpType.GET)
.type(InterceptType.AFTER)
.callbackClass("com.acme.FieldAuditor")
.callbackMethod("onFieldRead")
.build())
.build();
// Apply the bundle
InterceptManager manager = new InterceptManager(palDirectory);
ApplyResult result = manager.apply(bundle);
// result.getCreatedCount() == 3
Checking status and removing:
// Check which intercepts are active
BundleStatus status = manager.status(bundle);
// status.getActiveCount() / status.getTotalCount()
// Diff against current directory state
List<InterceptDiff> diffs = manager.diff(bundle);
// Each entry is CREATE, UNCHANGED, or MODIFIED
// Remove all intercepts in the bundle
RemoveResult removed = manager.remove(bundle);
// Or remove by bundle name (without the original spec)
RemoveResult removed = manager.removeByBundle("fraud-check-v1");
The programmatic API uses the same InterceptManager as the CLI commands. Applying a bundle is idempotent regardless of whether it was applied via YAML or the builder API.
Managing Intercepts
List Active Intercepts
List intercepts via the CLI or Java API:
# List all intercepts
pal intercept ls -d localhost:2379
# List with details (includes TTL column)
pal intercept ls -d localhost:2379 -l
Or query programmatically:
// All intercepts in the directory
Set<InterceptRequest> all = directory.listAllIntercepts();
// Only intercepts whose callback peer is the given peer
Set<InterceptRequest> forPeer = directory.listInterceptsForPeer(callbackPeerUuid);
Remove Intercept
// Both UUIDs are required: the callback peer and the intercept itself
directory.deleteIntercept(callbackPeerUuid, interceptUuid);
Update Intercept
Remove the old one and create a new one:
directory.deleteIntercept(callbackPeerUuid, oldInterceptUuid);
directory.createIntercept(newInterceptRequest);
Performance Impact
Overhead
Interception adds cost in three places. Concrete numbers depend on the JVM, host, payload size, and whether the callback peer is local or remote, so the descriptions below are qualitative.
- Woven call sites (always paid): A small bytecode-level thunk runs whether or not any intercept is active. The cost is negligible compared to typical method bodies.
- Pattern matching (only when intercepts are registered): On every woven call, PAL checks the active intercept set. Cost grows with the number of registered intercepts and depends on pattern specificity — a narrow pattern is cheaper to evaluate than a broad wildcard.
- Callback dispatch (only when an intercept matches):
- Synchronous (
BEFORE,AFTER,AROUND): a full RPC roundtrip to the callback peer; the intercepted call blocks until it completes. - Asynchronous (
BEFORE_ASYNC,AFTER_ASYNC): a one-way send; the intercepted call does not wait.
Optimization Tips
- Use specific patterns: Narrow patterns match fewer calls and are cheaper to evaluate.
- Remove unused intercepts: Reduces matching overhead on every woven call.
- Use async types for observability:
BEFORE_ASYNC/AFTER_ASYNCdon't block the caller. - Batch callbacks: Register one intercept for multiple methods
Debugging Interception
Intercept Not Firing
Check 1: Is application woven?
Should see AspectJ calls. If not, rebuild with AspectJ plugin.Check 2: Is peer interceptable?
Look for intercept support indicator.Check 3: Does pattern match?
# Enable debug logging in peer
<logger name="io.quasient.pal.core.intercept.InterceptMatcher" level="DEBUG"/>
Callback Peer Not Receiving
Check 1: Is callback peer running?
Check 2: Is callback peer's RPC endpoint correct?
Verify RPC endpoint is of type ZMQ and accessible.Limitations
Only Woven Code
Interception requires either the caller or the target to be compiled with AspectJ weaving. A call between two non-woven classes — for example, an unwoven third-party library invoking java.util.HashMap — is invisible to PAL.
When the target is woven, intercepts fire regardless of how the call arrives: reflection (Method.invoke), method references, lambdas, invokedynamic, JNI, and framework dispatchers (Quarkus, JavaFX, Spring MVC, etc.) all trigger them at the execution site.
When the caller is woven, intercepts fire at the call site even if the target is a JDK or third-party class — your woven code calling java.lang.String.length() or a closed-source library can be intercepted.
Pattern Syntax
Uses ant-style patterns, not regex:
*matches one level**matches multiple levels- No regex features like
(a|b)or[0-9]
No Type Hierarchy Matching
Intercept class pattern matching is by exact class name or Ant-style wildcard; it does not follow Java inheritance. Intercepting Animal does not intercept calls on Dog extends Animal unless Dog also matches the pattern. If you need to intercept all subclasses, use a wildcard pattern that covers them (e.g., com.example.animals.*).
Serialization Constraints
Callback argument and return value serialization is limited to simple types (primitives, strings, arrays of these). Complex objects are serialized by value, which may not preserve all state—particularly objects with transient fields, circular references, or non-serializable components.
Security Considerations
Anyone with directory access can register intercepts on any peer. To restrict:
- Enable etcd authentication (RBAC) and restrict which credentials can write under the intercept keyspace.
- Use network security — firewall etcd access and use mTLS — so untrusted clients cannot reach the directory.
- Implement authorization in your intercept handler so even an unauthorized registration cannot trigger privileged operations.
- Don't run untrusted code with
--interceptable.
Further Reading
- Peers and Logs - Understanding peers
- RPC - How callbacks are delivered
- CLI Reference: Intercept Commands - Full reference for bundle CLI commands
- Writing Callback Handlers - Implementing callback logic with practical examples
- Local Development - Setting up for interception