A request-by-request coin flip can send the same caller to different model versions. That makes a gradual release hard to reason about. A sticky canary instead gives each stable caller ID one cohort while the routing policy remains fixed.
When you need this. Use stable caller-based routing when repeated requests from one caller must keep the same selected version during a canary.
A cohort is the set of callers assigned to one route. SHA-256 hashes a fixed salt plus caller ID into 10,000 buckets; buckets below 2,000 select the canary. This is a 20% allocation rule, not an exact request count guarantee. Changing the salt or allocation can change membership. Caller IDs are synthetic fixture keys, not authentication credentials.
1. Follow the decision boundary
The local server chooses the recorded baseline for control users and the bias-plus-one parameter fixture for canary users. Each audit entry retains the user, request ID, arm and served model digest. The exercise sends all 590 fixed validation cases over real loopback HTTP, with 30 repeating synthetic users, then checks observed assignments across repeated users.
The lab reuses an exact copy of the recorded Rice predictor, bundle and dataset from registry and serving. It performs no training and uses no cloud service. Monitoring fixtures are explicitly authored; the rollout parameter variant is labeled in the result. Keep the complete extracted mlops-rollout-monitoring/ directory together; Python’s standard library is sufficient.
Read the implementation for this activity
"""Local routing around a recorded Rice predictor; no cloud deployment."""
import copy
import hashlib
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from predictor import decode, encode, digest, validate, predict, CONTRACT
HERE=Path(__file__).resolve().parent
SALT='rice-canary-fixture-v1'
def selected(data, expected, contract=CONTRACT):
if digest(data)!=expected:raise ValueError('selected model digest mismatch')
bundle=validate(decode(data))
if bundle['contract']!=contract:raise ValueError('serving feature contract incompatible')
return {'bundle':bundle,'version':expected}
def bundles():
raw=(HERE/'baseline-model.json').read_bytes();live=selected(raw,digest(raw))
altered=copy.deepcopy(live['bundle']);altered['bias']+=1.0
# Deliberate parameter fixture, not an independently trained candidate.
altered['fixture_variant']='recorded baseline bias plus 1.0; no training'
raw=encode(altered)
return live,selected(raw,digest(raw))
def identity(value):
if not isinstance(value,str) or not 1<=len(value)<=80:raise ValueError('bounded nonempty identity required')
return value
def cohort(user, basis_points=2000):
identity(user)
if type(basis_points) is not int or not 0<=basis_points<=10000:raise ValueError('invalid canary allocation')
bucket=int.from_bytes(hashlib.sha256((SALT+':'+user).encode()).digest()[:8],'big')%10000
return 'canary' if bucket<basis_points else 'control'
class Router:
def __init__(self,live,candidate):
self.live,self.candidate=live,candidate
self.active=live
self.audit=[]
self.lock=threading.Lock()
def infer(self,request,mode='shadow',basis_points=2000):
if not isinstance(request,dict) or set(request)!={'id','user','input'}:raise ValueError('routing envelope mismatch')
identity(request['id']);identity(request['user'])
if mode not in ('shadow','canary','active'):raise ValueError('unknown route mode')
arm=cohort(request['user'],basis_points) if mode=='canary' else 'control'
with self.lock:chosen=self.active if mode=='active' else self.candidate if arm=='canary' else self.live
answer=predict(chosen['bundle'],request['input'],chosen['version'])
entry={'id':request['id'],'user':request['user'],'arm':arm,'served':answer}
if mode=='shadow':
try:entry['shadow']=predict(self.candidate['bundle'],request['input'],self.candidate['version'])
except (ValueError,TypeError,OverflowError,KeyError) as error:entry['shadow_error']=type(error).__name__
with self.lock:self.audit.append(entry)
return answer
def rollback(self,data,expected,contract=CONTRACT):
candidate=selected(data,expected,contract)
# Swap the complete local selection after every check has succeeded.
with self.lock:
previous=self.active['version'];self.active=candidate
return {'previous':previous,'selected':expected,'contract':contract}
def server(router,mode='shadow',basis_points=2000):
class Handler(BaseHTTPRequestHandler):
def log_message(self,*_):pass
def reply(self,status,value):
data=encode(value);self.send_response(status);self.send_header('Content-Type','application/json')
self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
def do_POST(self):
if self.path!='/predict':self.reply(404,{'error':'unknown route'});return
try:
size=int(self.headers.get('Content-Length','0'))
if not 0<size<=4096:self.reply(413,{'error':'body size'});return
if self.headers.get_content_type()!='application/json':self.reply(415,{'error':'content type'});return
self.reply(200,router.infer(decode(self.rfile.read(size)),mode,basis_points))
except (ValueError,TypeError,OverflowError,KeyError):self.reply(422,{'error':'invalid request'})
return HTTPServer(('127.0.0.1',0),Handler)Download the complete lab, extract it and open mlops-rollout-monitoring/. The candidate is an intentionally altered parameter fixture used to demonstrate routing. It has not been approved by the earlier quality gate for a real release.
2. Run the focused activity
Run python exercise.py canary. The saved control cohort contains 24 users and 471 requests, with 437 correct responses. Canary contains six users and 119 requests, with 109 correct. Inspect each denominator before comparing rates. The candidate is an authored parameter variant, not a new training result or a demonstrated improvement.
python exercise.py canary{
"mode": "real loopback HTTP with synthetic user identities on fixed Rice validation rows",
"allocation_basis_points": 2000,
"hash_salt": "rice-canary-fixture-v1",
"cohorts": {
"control": {
"requests": 471,
"users": 24,
"correct": 437,
"live_counterfactual_correct": 437,
"candidate_counterfactual_correct": 434,
"accuracy": 0.9278131634819533
},
"canary": {
"requests": 119,
"users": 6,
"correct": 109,
"live_counterfactual_correct": 110,
"candidate_counterfactual_correct": 109,
"accuracy": 0.9159663865546218
}
},
"repeated_user_sticky": true,
"invalid_request_status": 422,
"requests": 590,
"candidate_kind": "recorded baseline bias +1.0 parameter fixture; no new training",
"limit": "Observed cohort rates are not a causal improvement estimate or a production experiment."
}The raw result, execution metadata and locally generated actual-output report retain the computed counts and source identities. Opening the report does not repeat execution. --record deliberately replaces that stage’s saved files; ordinary execution leaves them unchanged.

3. Inspect refusals before acting
| Failure condition | Required behavior |
|---|---|
| Incomplete routing comparison | Account for every served case, its cohort and its expected label; keep malformed-request refusals separate. |
| Invalid allocation or unstable cohorts | Stable identity hashing must preserve assignment across input order, and invalid allocation bounds must be refused. |
Removing the area feature yields HTTP 422 instead of a silent zero. test_canary_http_accounts_for_every_case checks the complete request count and refusal. test_cohort_reordering_and_extreme_allocations checks that input order does not change assignment, that 0% and 100% allocations have their stated behavior, and that invalid allocation types or bounds are refused.
python -m unittest -v test_continuation.pyChoose another salt in a temporary copy. Predict why membership and cohort counts can change even when every model parameter stays fixed. Keep the allocation and salt with the outcome report so a later comparison names the same policy.
How the result should guide the next step
The report also runs both model selections on each cohort’s inputs as offline counterfactual diagnostics: control has 437 baseline-correct versus 434 variant-correct cases; canary has 110 versus 109. These paired counts use already-known validation labels. They explain this fixture, not a live randomized improvement estimate. Repeated users, unequal cohort sizes and a fixed dataset constrain interpretation.
Check your understanding
Read the explanation
Sticky assignment is per caller under a fixed hash policy. Repeated callers and finite membership affect the request count.
References
Official primary sources checked October 24, 2026. The local records supply this lesson’s numbers and refusals.
- Google ML Crash Course: monitoring: distinguish data checks, distributions and live model quality.
- Python hashlib: deterministic byte digests used by this local implementation.
- Python HTTP server: local server APIs and production limitations.
- Cloud Deploy canary concepts: subset rollout terminology. This lab does not run Cloud Deploy or reproduce its orchestration.