Flow-based gating: one gate per step, wired to how your business flow is actually structured.
A checkout isn't a single call — it's an entry point plus a handful of critical steps (payment, inventory, notifications), sometimes with alternate paths (EMI, UPI, COD). A plain circuit breaker treats all of that as one blob: any failure trips the whole flow, and bringing it back gives you one probe request and then full traffic — no gradual ramp, no early abort if the fix didn't hold, and no way to tell which part of the flow was actually the problem.
canary-gate maps a tree of independent gates onto that structure instead. Annotate the entry point with @GatedFlow, and annotate the steps and sub-flows inside it — @FlowStep, or nested branches in config — and each one gets its own state machine, failure threshold, and HALF_OPEN rollout. A step's failures open that step's gate, not the whole flow: the parent keeps admitting everyone and reports PARTIAL instead of BLOCKED, so callers know exactly which piece of functionality is degraded while the rest of the flow keeps working.
Every gate in that tree — flow or branch, top-level or nested arbitrarily deep — also gets the same tiered HALF_OPEN rollout when it comes back: 5% → 10% → 25% → 50%, one tier at a time, with automatic regression detection at each step. If a tier fails mid-way, that gate reverts to OPEN immediately. If all tiers pass, it closes itself.
Two annotations. A gate tree that mirrors your flow. Zero boilerplate.
1. Add the dependency
<dependency>
<groupId>io.github.growwoss</groupId>
<artifactId>canary-gate</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>2. Annotate your entry point and critical steps
@GatedFlow("checkout")
public CheckoutResponse checkout(String sessionId, CheckoutRequest req) {
// gate checks admission before this runs
// GateBlockedException thrown if gate is OPEN
return doCheckout(req);
}
@FlowStep("payment-initiate")
public PaymentResult initiatePayment(PaymentRequest req) {
// failure rate here is tracked by the health engine
// breaches threshold → gate auto-opens
return paymentClient.initiate(req);
}3. Configure
canary-gate:
flows:
checkout:
open-ttl: 30m # auto-close OPEN gate after 30 min (optional)
half-open:
mode: PERCENTAGE
progression: [5, 10, 25, 50, 100] # 100 = auto-close sentinel
ttl: 2m # session timeout if outcome never reported
branches:
payment-initiate:
failure-threshold: 0.30
minimum-throughput: 20
emi-split:
half-open:
mode: PERCENTAGE
progression: [10, 50, 100]That's it. The gate is live.
@GatedFlow("checkout") registers one top-level gate, checkout. Every branches entry under it in config — and every @FlowStep name that matches one — registers another gate, nested under that path (checkout.payment-initiate, checkout.emi-split.3-month, and so on, arbitrarily deep). Each node in that tree runs its own independent copy of everything below: its own state (CLOSED/OPEN/HALF_OPEN), its own failure threshold, its own HALF_OPEN progression.
isAllowed("checkout", sessionId) only ever answers for the checkout node itself, plus a scan of its descendants: if checkout is CLOSED but a descendant (say payment-initiate) is OPEN or HALF_OPEN, the flow still returns PARTIAL rather than ALLOWED — the caller knows something downstream is degraded without the whole flow being blocked. A @FlowStep failing enough times only ever opens its own gate, never its parent's, unless the parent flow has its own independent failure signal (a downtime contributor, admin action, or its own @GatedFlow/step traffic).
step failure / contributor / admin
│
┌──────────▼──────────┐
│ OPEN │◄─── revert() (admin)
│ all traffic │
│ blocked │
└──────────┬──────────┘
open-ttl fires │ or openValidationWindow() (admin)
│
┌──────────▼──────────┐
│ HALF_OPEN │
│ tiered admission │
│ 5% → 10% → 25%... │
└──────────┬──────────┘
all tiers pass │ or restore() (admin)
│
┌──────────▼──────────┐
│ CLOSED │
│ full traffic flows │
└─────────────────────┘
| Gate state | Session | Permission |
|---|---|---|
| CLOSED, all descendants healthy | any | ALLOWED |
| CLOSED, a branch is OPEN | any | PARTIAL |
| OPEN | any | BLOCKED → throws GateBlockedException |
| HALF_OPEN, session admitted (guinea pig) | matching session | PARTIAL |
| HALF_OPEN, session not admitted | non-admitted | BLOCKED |
When the gate enters HALF_OPEN, the library records the current traffic count as a baseline. Each tier defines a percentage of that baseline to admit:
progression: [5, 10, 25, 50, 100]
└─ sentinel: skip tier, auto-close
A tier advances when all admitted sessions in that tier have reported an outcome and the failure rate never crossed the threshold mid-tier. If it does, the gate reverts to OPEN immediately.
Sessions that never report an outcome are resolved as failures after ttl — tiers never get stuck.
A branch is a nested gate inside a flow — the same building block used for both feature-level sub-flows and individual @FlowStep health checks. It has its own full state machine, failure threshold, and HALF_OPEN lifecycle. When a branch is OPEN, the parent gate returns PARTIAL (not BLOCKED) — the flow still works, just without that feature.
canary-gate:
flows:
checkout:
branches:
emi-split: # independent gate for EMI payment path
half-open:
mode: PERCENTAGE
progression: [10, 50, 100]@GatedFlow("checkout") // checks checkout AND marks which branch is in use
public CheckoutResponse checkout(String sessionId, CheckoutRequest req) { ... }The gate opens automatically when any of these fire:
1. Step health — @FlowStep tracks failure rate per window against a named branch. Crossing that branch's failure-threshold with at least minimum-throughput calls triggers transitionTo(OPEN) on it directly.
2. Downtime contributor — register a bean that pings an external dependency. The library polls every 30 seconds.
@Component
public class PaymentDbContributor implements DowntimeContributor {
@Override public String flowName() { return "checkout"; }
@Override
public DowntimeSignal check() {
return db.isReachable() ? DowntimeSignal.UP : DowntimeSignal.DOWN;
}
}3. Admin API — call CanaryGateService.declareDowntime("checkout") from your ops endpoint.
@Autowired CanaryGateService gate;
gate.declareDowntime("checkout"); // force OPEN
gate.openValidationWindow("checkout"); // OPEN → HALF_OPEN (start rollout)
gate.restore("checkout"); // force CLOSED
gate.revert("checkout"); // HALF_OPEN → OPEN (abort rollout)
GateStatus status = gate.getStatus("checkout");
Collection<GateStatus> all = gate.getAllStates();All three SPIs are @ConditionalOnMissingBean — register your own bean to replace the default.
Replace the default in-memory store with Redis, JDBC, or any backend:
@Bean
public GateStateStore redisGateStateStore(RedisTemplate<String, String> redis) {
return new RedisGateStateStore(redis);
}By default, the first String argument of the @GatedFlow method is treated as the session ID. Override this to pull from a JWT, HTTP header, or ThreadLocal:
@Bean
public SessionIdResolver jwtSessionIdResolver() {
return args -> SecurityContextHolder.getContext()
.getAuthentication().getName();
}React to gate transitions for alerting, metrics, or audit:
@Component
public class GateAlertListener {
@EventListener
public void on(GateStateChangedEvent e) {
if (e.getNext() == GateState.OPEN) {
pagerduty.trigger("gate " + e.getFlowName() + " opened: " + e.getReason());
}
}
}canary-gate:
flows:
<flow-name>:
failure-threshold: 0.30 # 0.0–1.0 (default: 0.30)
open-ttl: 30m # auto-advance OPEN → CLOSED after duration (default: disabled)
minimum-throughput: 20 # min calls before threshold is evaluated (default: 5)
optional: false # if true, failures here never open the gate (default: false)
half-open:
mode: PERCENTAGE # PERCENTAGE | WHITELIST
progression: [5, 10, 25, 50, 100]
ttl: 2m # session outcome timeout
whitelist: # used when mode: WHITELIST
- session-id-1
- session-id-2
branches:
<branch-name>: # a branch may be a feature sub-flow or a @FlowStep target
failure-threshold: 0.30
minimum-throughput: 20
optional: false
half-open: { ... }
branches: { ... } # branches nest arbitrarily deep- Java 21+
- Spring Boot 3.2+
Apache 2.0