session
¶
Establishing a broker session, once, safely, from however many processes want one.
Logging in is a side effect of constructing a broker's API class, which probes an authenticated
endpoint and only runs the real login flow when that probe fails. A process that only reads
last_login from MongoDB refreshes nothing - one that has been up across the overnight token
expiry will keep sending requests against a dead session unless something constructs the API object.
That is what this module is for. The historical candle downloaders call it through BrokerCandles
when they log in, and IND Money's instrument ingester calls it when no usable token is stored. Two
things make it more than a one line helper.
Only one login at a time. Kite issues one access token per session and a second login invalidates the first. Two processes starting together and both logging in would race, and the loser would come up holding a token that had already been replaced. A Redis lock makes one of them do the work while the other waits for the result.
A floor on how often a login may be attempted. Several brokers log in by driving a headless
Chrome and consuming a TOTP. Under Restart=always, a broker outage would otherwise mean a full
Selenium login every time the process restarted - a few times a minute, indefinitely, which is
how an account gets locked. The rate limiter bounds that regardless of how often the service is
restarted, because it lives in Redis rather than in the process.
Classes:
| Name | Description |
|---|---|
SessionException |
Raised when a broker session could not be established. |
Functions:
| Name | Description |
|---|---|
api_class_for |
The API class whose construction logs a broker in. |
stored_login |
A broker's |
ensure_session |
Make sure a usable session exists for a broker, logging in if it does not. |
SessionException
¶
Bases: Exception
Raised when a broker session could not be established.
Example programs
"""Catches the `SessionException` that `ensure_session` raises when a broker's login fails.
`ensure_session` is how the candle downloaders and IND Money's instrument download make sure a broker is logged in. It takes a Redis lock so that only one process logs a broker in at a time, records the attempt so that a failing login is not retried more than once every five minutes, and then constructs the broker's API class, whose constructor does the login. Whatever goes wrong inside is re-raised as a `SessionException` naming the broker, with the original error kept as its cause, and the lock is released either way.
Constructing a real broker class would log in to that broker, so this program replaces three things in the session module for the length of the call: `get_cache` returns a small stand-in for Redis, `get_mongo_db` a stand-in for MongoDB with no stored login, and `api_class_for` a stand-in IND Money class whose constructor fails the way a rejected TOTP does. Nothing reaches a data store, the network or a broker.
Notice that the exception's cause is the broker's own error, that the lock is gone afterwards, and that the attempt was recorded but no success was.
Run it from the project root:
python examples/stock_brokers/api/utilities/session/SessionException/example_1_a_login_that_fails.py
"""
import logging
import unittest.mock
from stock_brokers.api.indmoney import (
INDMoneyAPIException,
)
from stock_brokers.api.utilities import session as session_module
from stock_brokers.api.utilities.session import (
SessionException,
)
class StandInRedis:
"""A dictionary-backed stand-in for the Redis commands `ensure_session` uses.
Attributes:
keys (dict): Each key mapped to its value.
"""
def __init__(self):
"""Starts with nothing stored.
Returns:
None: This method returns nothing.
"""
self.keys = {}
def get(self, name):
"""Reads a key.
Args:
name (str): The key.
Returns:
str | None: The value, or None when the key is absent.
"""
return self.keys.get(name)
def set(self, name, value, nx=False, ex=None):
"""Sets a key, only when it is absent if `nx` is given.
Args:
name (str): The key.
value (str): The value to store.
nx (bool): True to set the key only when it does not exist.
ex (int | None): The expiry in seconds, which the stand-in ignores.
Returns:
bool: True when the key was set.
"""
if nx and name in self.keys:
return False
self.keys[name] = value
return True
def delete(self, name):
"""Removes a key.
Args:
name (str): The key.
Returns:
int: 1 when the key existed, otherwise 0.
"""
if name in self.keys:
del self.keys[name]
return 1
return 0
class EmptyCollection:
"""A stand-in for a MongoDB collection that holds no documents."""
def find_one(self, query, projection=None):
"""Finds nothing.
Args:
query (dict): The fields to match.
projection (dict | None): The fields to return.
Returns:
None: There is never a match.
"""
return None
class EmptyMongo:
"""A stand-in for the MongoDB database in which every collection is empty."""
def __getitem__(self, name):
"""Returns an empty collection, as `database[name]` does.
Args:
name (str): The collection name.
Returns:
EmptyCollection: A collection with no documents.
"""
return EmptyCollection()
class RefusedINDMoneyAPI:
"""A stand-in for `INDMoneyAPI` whose construction, the login, is refused."""
def __init__(self):
"""Fails the login as IND Money does for a TOTP it rejects.
Returns:
None: This method never returns.
Raises:
INDMoneyAPIException: Always.
"""
raise INDMoneyAPIException(code=401, message='Cannot generate access token from INDstocks API: invalid TOTP')
class ALoginThatFailsExample:
"""Asks for an IND Money session whose login fails and prints the resulting exception.
Attributes:
cache (StandInRedis): The stand-in for Redis, holding the lock and the attempt markers.
logger (logging.Logger): Where `ensure_session` reports its progress.
"""
def __init__(self):
"""Builds the stand-in Redis and the logger.
Returns:
None: This method returns nothing.
"""
self.cache = StandInRedis()
self.logger = logging.getLogger('indmoney.session')
def run(self):
"""Calls `ensure_session`, catches the `SessionException` and prints what it and Redis hold.
Returns:
None: This method returns nothing.
"""
with unittest.mock.patch.object(session_module, 'get_cache', return_value=self.cache):
with unittest.mock.patch.object(session_module, 'get_mongo_db', return_value=EmptyMongo()):
with unittest.mock.patch.object(session_module, 'api_class_for', return_value=RefusedINDMoneyAPI):
try:
session_module.ensure_session('indmoney', logger=self.logger)
except SessionException as error:
print(f'Caught: {type(error).__name__}')
print(f'message: {error}')
print(f'cause: {type(error.__cause__).__name__} code={error.__cause__.code}')
print(f'Login lock still held: {"ubi:login:indmoney" in self.cache.keys}')
print(f'Attempt recorded: {"ubi:login-attempt:indmoney" in self.cache.keys}')
print(f'Success recorded: {"ubi:login-ok:indmoney" in self.cache.keys}')
if __name__ == '__main__':
ALoginThatFailsExample().run()
It prints:
"""Catches the `SessionException` that `ensure_session` raises when a login finishes without a usable token.
A broker class's constructor can return without an error and still leave no working token behind, for example when a broker changed its login answer and an earlier failed login had stored the string "None" in place of a token. `ensure_session` does not trust the constructor alone: after it returns, it reads the stored login from MongoDB again and raises a `SessionException` when the token there is missing or "None", so a caller never goes on to send requests with nothing to authenticate them.
Constructing a real broker class would log in to that broker, so this program replaces three things in the session module for the length of the call: `get_cache` returns a small stand-in for Redis, `get_mongo_db` a stand-in for MongoDB whose stored IND Money login holds "None", and `api_class_for` a stand-in IND Money class whose constructor returns quietly without storing anything. Nothing reaches a data store, the network or a broker.
Notice that this exception has no cause, because nothing inside raised, and that the lock was released before the stored login was checked.
Run it from the project root:
python examples/stock_brokers/api/utilities/session/SessionException/example_2_a_login_that_stores_no_token.py
"""
import logging
import unittest.mock
from stock_brokers.api.utilities import session as session_module
from stock_brokers.api.utilities.session import (
SessionException,
)
class StandInRedis:
"""A dictionary-backed stand-in for the Redis commands `ensure_session` uses.
Attributes:
keys (dict): Each key mapped to its value.
"""
def __init__(self):
"""Starts with nothing stored.
Returns:
None: This method returns nothing.
"""
self.keys = {}
def get(self, name):
"""Reads a key.
Args:
name (str): The key.
Returns:
str | None: The value, or None when the key is absent.
"""
return self.keys.get(name)
def set(self, name, value, nx=False, ex=None):
"""Sets a key, only when it is absent if `nx` is given.
Args:
name (str): The key.
value (str): The value to store.
nx (bool): True to set the key only when it does not exist.
ex (int | None): The expiry in seconds, which the stand-in ignores.
Returns:
bool: True when the key was set.
"""
if nx and name in self.keys:
return False
self.keys[name] = value
return True
def delete(self, name):
"""Removes a key.
Args:
name (str): The key.
Returns:
int: 1 when the key existed, otherwise 0.
"""
if name in self.keys:
del self.keys[name]
return 1
return 0
class StoredLoginCollection:
"""A stand-in for the MongoDB `last_login` collection, holding one document.
Attributes:
document (dict): The stored login.
"""
def __init__(self, document):
"""Holds the stored login.
Args:
document (dict): The stored login.
Returns:
None: This method returns nothing.
"""
self.document = document
def find_one(self, query, projection=None):
"""Returns the stored login when the query names its broker.
Args:
query (dict): The fields to match, here `broker_name`.
projection (dict | None): The fields to return, which the stand-in ignores.
Returns:
dict | None: A copy of the stored login, or None for another broker.
"""
if query.get('broker_name') != self.document['broker_name']:
return None
return dict(self.document)
class StandInMongo:
"""A stand-in for the MongoDB database, holding only the `last_login` collection.
Attributes:
last_login (StoredLoginCollection): The `last_login` collection.
"""
def __init__(self, last_login):
"""Holds the collection.
Args:
last_login (StoredLoginCollection): The `last_login` collection.
Returns:
None: This method returns nothing.
"""
self.last_login = last_login
def __getitem__(self, name):
"""Returns the `last_login` collection, as `database['last_login']` does.
Args:
name (str): The collection name, always `last_login` here.
Returns:
StoredLoginCollection: The collection.
"""
return self.last_login
class QuietINDMoneyAPI:
"""A stand-in for `INDMoneyAPI` whose construction returns without storing a token."""
def __init__(self):
"""Returns without doing anything, as a login that silently stored nothing would.
Returns:
None: This method returns nothing.
"""
class ALoginThatStoresNoTokenExample:
"""Asks for an IND Money session whose login leaves no usable token and prints the resulting exception.
Attributes:
cache (StandInRedis): The stand-in for Redis, holding the lock and the attempt markers.
mongo_db (StandInMongo): The stand-in for MongoDB, holding a failed login.
logger (logging.Logger): Where `ensure_session` reports its progress.
"""
def __init__(self):
"""Builds the stand-in data stores and the logger.
Returns:
None: This method returns nothing.
"""
self.cache = StandInRedis()
self.mongo_db = StandInMongo(StoredLoginCollection({
'broker_name': 'indmoney',
'access_token': 'None',
'last_login': '2026-09-29 07:00:04',
}))
self.logger = logging.getLogger('indmoney.session')
def run(self):
"""Calls `ensure_session`, catches the `SessionException` and prints what it and Redis hold.
Returns:
None: This method returns nothing.
"""
with unittest.mock.patch.object(session_module, 'get_cache', return_value=self.cache):
with unittest.mock.patch.object(session_module, 'get_mongo_db', return_value=self.mongo_db):
with unittest.mock.patch.object(session_module, 'api_class_for', return_value=QuietINDMoneyAPI):
try:
session_module.ensure_session('indmoney', logger=self.logger)
except SessionException as error:
print(f'Caught: {type(error).__name__}')
print(f'message: {error}')
print(f'cause: {error.__cause__}')
print(f'Login lock still held: {"ubi:login:indmoney" in self.cache.keys}')
if __name__ == '__main__':
ALoginThatStoresNoTokenExample().run()
It prints:
_lock_key
¶
Redis key held while one process logs a broker in.
broker_nameis the name of the broker.
_attempt_key
¶
Redis key recording when a broker was last sent through its login flow.
broker_nameis the name of the broker.
_success_key
¶
Redis key recording when a broker's session was last confirmed working.
Distinct from the attempt key because the usual outcome of ensure_session is that the
stored token was already valid and only a probe happened, which changes nothing in MongoDB.
Without a separate record there is no way to tell that from an attempt that failed.
broker_nameis the name of the broker.
Source code in stock_brokers/api/utilities/session.py
api_class_for
¶
The API class whose construction logs a broker in.
Imported lazily and one at a time: several of these modules import Selenium at module level, and a process that only needs Zerodha should not pay for the other nine.
broker_nameis the name of the broker.
Source code in stock_brokers/api/utilities/session.py
stored_login
¶
A broker's last_login document, or an empty dictionary.
broker_nameis the name of the broker.
Source code in stock_brokers/api/utilities/session.py
_login_timestamp
¶
The moment a stored login was made, as an epoch, or None when it cannot be read.
documentis alast_logindocument.
Source code in stock_brokers/api/utilities/session.py
_clear_dead_holder
¶
Release the login lock if the process holding it no longer exists.
The lock has an expiry, so a crashed holder always clears eventually - but eventually is up
to five minutes, and this is reached on the ordinary path of restarting a service while it is
inside ensure_session, before its signal handlers are installed. Waiting out the expiry
every time would make a restart look like a hang.
The lock holds the holder's process id, and everything that takes it runs on this machine, so a liveness check is a signal 0. Returns whether the lock was cleared.
broker_nameis the name of the broker.cacheis the Redis client.loggeris where the release is reported.
Source code in stock_brokers/api/utilities/session.py
ensure_session
¶
Make sure a usable session exists for a broker, logging in if it does not.
Constructing the broker's API class is what does the work: it probes an authenticated endpoint first, so the common case where the stored token is still good costs one HTTP request rather than a login.
Returns the last_login document in force after the call.
broker_nameis the name of the broker.forceskips the rate limiter, for a deliberate manual re-login.loggeris where progress is reported. A named logger is made when not supplied.
Source code in stock_brokers/api/utilities/session.py
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | |
_wait_for_other_process
¶
Wait for whichever process holds the lock to finish, then use its result.
Succeeds as soon as the stored login is newer than this call began, or as soon as the lock is gone and a usable token is present - the second case covers a login that was already valid and so wrote nothing new.
broker_nameis the name of the broker.cacheis the Redis client.started_atis the epoch at which this call began.loggeris where progress is reported.