1616from ..cache import AsyncCacheProvider , AsyncLocalCache
1717from .merge import partial_company , partial_user
1818from .rules_engine import RulesEngineClient
19- from .types import DataStreamBaseReq , DataStreamReq , DataStreamResp , EntityType , MessageType
19+ from .types import DataStreamBaseReq , DataStreamReq , DataStreamResp , EntityType , KeyConflictError , MessageType
2020from .websocket_client import ClientOptions as WSClientOptions , DatastreamWSClient
2121
2222
@@ -363,7 +363,10 @@ async def get_all_flags(self) -> None:
363363 try :
364364 await asyncio .wait_for (asyncio .shield (self ._pending_flags ), timeout = RESOURCE_TIMEOUT_MS / 1000 )
365365 except asyncio .TimeoutError :
366+ fut = self ._pending_flags
366367 self ._pending_flags = None
368+ if fut is not None and not fut .done ():
369+ fut .set_result (False )
367370 raise TimeoutError ("Timeout while waiting for flags data" )
368371
369372 async def check_flag (
@@ -384,10 +387,20 @@ async def check_flag(
384387 cached_company : Optional [Any ] = None
385388 cached_user : Optional [Any ] = None
386389
387- if needs_company :
388- cached_company = await self ._get_company_from_cache (company_keys ) # type: ignore[arg-type]
389- if needs_user :
390- cached_user = await self ._get_user_from_cache (user_keys ) # type: ignore[arg-type]
390+ try :
391+ if needs_company :
392+ cached_company = await self ._get_company_from_cache (company_keys ) # type: ignore[arg-type]
393+ if needs_user :
394+ cached_user = await self ._get_user_from_cache (user_keys ) # type: ignore[arg-type]
395+ except KeyConflictError as exc :
396+ self ._logger .warning ("Key conflict for flag %s: %s" , flag_key , exc )
397+ return RulesengineCheckFlagResult (
398+ value = flag .default_value ,
399+ reason = "key conflict" ,
400+ flag_key = flag .key ,
401+ flag_id = flag .id ,
402+ err = str (exc ),
403+ )
391404
392405 # Replicator mode — evaluate with whatever is cached
393406 if self ._replicator_mode :
@@ -670,35 +683,65 @@ async def _send_request(self, request: DataStreamReq) -> None:
670683 # ------------------------------------------------------------------
671684
672685 async def _get_company_from_cache (self , keys : Dict [str , str ]) -> Optional [RulesengineCompany ]:
686+ matched_id : Optional [str ] = None
673687 for key , value in keys .items ():
674688 ck = self ._resource_key_to_cache_key (_PREFIX_COMPANY , key , value )
675689 try :
676690 company_id = await self ._company_key_cache .get (ck )
677- self ._logger .debug ("Company lookup key %s -> %s" , ck , company_id )
678- if company_id :
679- rk = self ._resource_id_cache_key (_PREFIX_COMPANY , company_id )
680- raw = await self ._company_cache .get (rk )
681- self ._logger .debug ("Company ID key %s -> %s" , rk , "hit" if raw is not None else "miss" )
682- if raw is not None :
683- company = _validate (RulesengineCompany , raw )
684- return company .model_copy (deep = True )
685691 except Exception as exc :
686692 self ._logger .warning ("Failed to retrieve company from cache: %s" , exc )
693+ continue
694+ self ._logger .debug ("Company lookup key %s -> %s" , ck , company_id )
695+ if not company_id :
696+ continue
697+ if matched_id is None :
698+ matched_id = company_id
699+ elif matched_id != company_id :
700+ raise KeyConflictError (
701+ f"Company keys match multiple entities: { matched_id } and { company_id } "
702+ )
703+
704+ if matched_id is None :
705+ return None
706+
707+ try :
708+ rk = self ._resource_id_cache_key (_PREFIX_COMPANY , matched_id )
709+ raw = await self ._company_cache .get (rk )
710+ self ._logger .debug ("Company ID key %s -> %s" , rk , "hit" if raw is not None else "miss" )
711+ if raw is not None :
712+ return _validate (RulesengineCompany , raw ).model_copy (deep = True )
713+ except Exception as exc :
714+ self ._logger .warning ("Failed to retrieve company from cache: %s" , exc )
687715 return None
688716
689717 async def _get_user_from_cache (self , keys : Dict [str , str ]) -> Optional [RulesengineUser ]:
718+ matched_id : Optional [str ] = None
690719 for key , value in keys .items ():
691720 ck = self ._resource_key_to_cache_key (_PREFIX_USER , key , value )
692721 try :
693722 user_id = await self ._user_key_cache .get (ck )
694- if user_id :
695- rk = self ._resource_id_cache_key (_PREFIX_USER , user_id )
696- raw = await self ._user_cache .get (rk )
697- if raw is not None :
698- user = _validate (RulesengineUser , raw )
699- return user .model_copy (deep = True )
700723 except Exception as exc :
701724 self ._logger .warning ("Failed to retrieve user from cache: %s" , exc )
725+ continue
726+ if not user_id :
727+ continue
728+ if matched_id is None :
729+ matched_id = user_id
730+ elif matched_id != user_id :
731+ raise KeyConflictError (
732+ f"User keys match multiple entities: { matched_id } and { user_id } "
733+ )
734+
735+ if matched_id is None :
736+ return None
737+
738+ try :
739+ rk = self ._resource_id_cache_key (_PREFIX_USER , matched_id )
740+ raw = await self ._user_cache .get (rk )
741+ if raw is not None :
742+ return _validate (RulesengineUser , raw ).model_copy (deep = True )
743+ except Exception as exc :
744+ self ._logger .warning ("Failed to retrieve user from cache: %s" , exc )
702745 return None
703746
704747 async def _cache_company (self , company : RulesengineCompany ) -> None :
@@ -956,7 +999,8 @@ def _make_default_result(
956999 def _start_replicator_health_check (self ) -> None :
9571000 if not self ._replicator_health_url :
9581001 return
959- self ._health_check_client = httpx .AsyncClient (timeout = REPLICATOR_HEALTH_TIMEOUT_S )
1002+ if self ._health_check_client is None :
1003+ self ._health_check_client = httpx .AsyncClient (timeout = REPLICATOR_HEALTH_TIMEOUT_S )
9601004 self ._logger .info (
9611005 "Starting replicator health check: url=%s, interval=%dms" ,
9621006 self ._replicator_health_url , self ._replicator_health_check_ms ,
0 commit comments