Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

canary-gate

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.


Quick start

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.


How it works

The gate tree

@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).

State machine

           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 permissions

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

Tier progression

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.

Branches

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) { ... }

Auto-open triggers

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.


Admin API

@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();

Extension points

All three SPIs are @ConditionalOnMissingBean — register your own bean to replace the default.

Persistent state store

Replace the default in-memory store with Redis, JDBC, or any backend:

@Bean
public GateStateStore redisGateStateStore(RedisTemplate<String, String> redis) {
    return new RedisGateStateStore(redis);
}

Session ID resolver

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();
}

State change listener

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());
        }
    }
}

Full configuration reference

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

Requirements

  • Java 21+
  • Spring Boot 3.2+

License

Apache 2.0

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages