roomkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roomkit/AGENTS.md +362 -0
- roomkit/__init__.py +372 -0
- roomkit/_version.py +1 -0
- roomkit/ai_docs.py +93 -0
- roomkit/channels/__init__.py +194 -0
- roomkit/channels/ai.py +238 -0
- roomkit/channels/base.py +66 -0
- roomkit/channels/transport.py +115 -0
- roomkit/channels/websocket.py +85 -0
- roomkit/core/__init__.py +0 -0
- roomkit/core/_channel_ops.py +252 -0
- roomkit/core/_helpers.py +296 -0
- roomkit/core/_inbound.py +435 -0
- roomkit/core/_room_lifecycle.py +275 -0
- roomkit/core/circuit_breaker.py +84 -0
- roomkit/core/event_router.py +401 -0
- roomkit/core/framework.py +793 -0
- roomkit/core/hooks.py +232 -0
- roomkit/core/inbound_router.py +57 -0
- roomkit/core/locks.py +66 -0
- roomkit/core/rate_limiter.py +67 -0
- roomkit/core/retry.py +49 -0
- roomkit/core/router.py +24 -0
- roomkit/core/transcoder.py +85 -0
- roomkit/identity/__init__.py +0 -0
- roomkit/identity/base.py +27 -0
- roomkit/identity/mock.py +49 -0
- roomkit/llms.txt +52 -0
- roomkit/models/__init__.py +104 -0
- roomkit/models/channel.py +99 -0
- roomkit/models/context.py +35 -0
- roomkit/models/delivery.py +76 -0
- roomkit/models/enums.py +170 -0
- roomkit/models/event.py +203 -0
- roomkit/models/framework_event.py +19 -0
- roomkit/models/hook.py +68 -0
- roomkit/models/identity.py +81 -0
- roomkit/models/participant.py +34 -0
- roomkit/models/room.py +33 -0
- roomkit/models/task.py +36 -0
- roomkit/providers/__init__.py +0 -0
- roomkit/providers/ai/__init__.py +0 -0
- roomkit/providers/ai/base.py +140 -0
- roomkit/providers/ai/mock.py +33 -0
- roomkit/providers/anthropic/__init__.py +6 -0
- roomkit/providers/anthropic/ai.py +145 -0
- roomkit/providers/anthropic/config.py +14 -0
- roomkit/providers/elasticemail/__init__.py +6 -0
- roomkit/providers/elasticemail/config.py +16 -0
- roomkit/providers/elasticemail/email.py +97 -0
- roomkit/providers/email/__init__.py +0 -0
- roomkit/providers/email/base.py +46 -0
- roomkit/providers/email/mock.py +34 -0
- roomkit/providers/gemini/__init__.py +6 -0
- roomkit/providers/gemini/ai.py +153 -0
- roomkit/providers/gemini/config.py +14 -0
- roomkit/providers/http/__init__.py +15 -0
- roomkit/providers/http/base.py +33 -0
- roomkit/providers/http/config.py +14 -0
- roomkit/providers/http/mock.py +21 -0
- roomkit/providers/http/provider.py +105 -0
- roomkit/providers/http/webhook.py +33 -0
- roomkit/providers/messenger/__init__.py +15 -0
- roomkit/providers/messenger/base.py +33 -0
- roomkit/providers/messenger/config.py +17 -0
- roomkit/providers/messenger/facebook.py +95 -0
- roomkit/providers/messenger/mock.py +21 -0
- roomkit/providers/messenger/webhook.py +42 -0
- roomkit/providers/openai/__init__.py +6 -0
- roomkit/providers/openai/ai.py +155 -0
- roomkit/providers/openai/config.py +24 -0
- roomkit/providers/pydantic_ai/__init__.py +5 -0
- roomkit/providers/pydantic_ai/config.py +14 -0
- roomkit/providers/rcs/__init__.py +9 -0
- roomkit/providers/rcs/base.py +95 -0
- roomkit/providers/rcs/mock.py +78 -0
- roomkit/providers/sendgrid/__init__.py +5 -0
- roomkit/providers/sendgrid/config.py +13 -0
- roomkit/providers/sinch/__init__.py +6 -0
- roomkit/providers/sinch/config.py +22 -0
- roomkit/providers/sinch/sms.py +192 -0
- roomkit/providers/sms/__init__.py +15 -0
- roomkit/providers/sms/base.py +67 -0
- roomkit/providers/sms/meta.py +401 -0
- roomkit/providers/sms/mock.py +24 -0
- roomkit/providers/sms/phone.py +77 -0
- roomkit/providers/telnyx/__init__.py +21 -0
- roomkit/providers/telnyx/config.py +14 -0
- roomkit/providers/telnyx/rcs.py +352 -0
- roomkit/providers/telnyx/sms.py +231 -0
- roomkit/providers/twilio/__init__.py +18 -0
- roomkit/providers/twilio/config.py +19 -0
- roomkit/providers/twilio/rcs.py +183 -0
- roomkit/providers/twilio/sms.py +200 -0
- roomkit/providers/voicemeup/__init__.py +15 -0
- roomkit/providers/voicemeup/config.py +21 -0
- roomkit/providers/voicemeup/sms.py +374 -0
- roomkit/providers/whatsapp/__init__.py +0 -0
- roomkit/providers/whatsapp/base.py +44 -0
- roomkit/providers/whatsapp/mock.py +21 -0
- roomkit/py.typed +0 -0
- roomkit/realtime/__init__.py +17 -0
- roomkit/realtime/base.py +111 -0
- roomkit/realtime/memory.py +158 -0
- roomkit/sources/__init__.py +35 -0
- roomkit/sources/base.py +207 -0
- roomkit/sources/websocket.py +260 -0
- roomkit/store/__init__.py +0 -0
- roomkit/store/base.py +230 -0
- roomkit/store/memory.py +293 -0
- roomkit-0.1.0.dist-info/METADATA +567 -0
- roomkit-0.1.0.dist-info/RECORD +114 -0
- roomkit-0.1.0.dist-info/WHEEL +4 -0
- roomkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""RoomLifecycleMixin — room CRUD and participant management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from roomkit.core._helpers import HelpersMixin
|
|
11
|
+
from roomkit.models.enums import (
|
|
12
|
+
EventType,
|
|
13
|
+
HookTrigger,
|
|
14
|
+
IdentificationStatus,
|
|
15
|
+
RoomStatus,
|
|
16
|
+
)
|
|
17
|
+
from roomkit.models.participant import Participant
|
|
18
|
+
from roomkit.models.room import Room
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from roomkit.core.locks import RoomLockManager
|
|
22
|
+
from roomkit.store.base import ConversationStore
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("roomkit.framework")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RoomLifecycleMixin(HelpersMixin):
|
|
28
|
+
"""Room lifecycle operations: create, close, timers, participants."""
|
|
29
|
+
|
|
30
|
+
_store: ConversationStore
|
|
31
|
+
_lock_manager: RoomLockManager
|
|
32
|
+
|
|
33
|
+
async def create_room(
|
|
34
|
+
self, room_id: str | None = None, metadata: dict[str, Any] | None = None
|
|
35
|
+
) -> Room:
|
|
36
|
+
"""Create a new room."""
|
|
37
|
+
room = Room(
|
|
38
|
+
id=room_id or uuid4().hex,
|
|
39
|
+
metadata=metadata or {},
|
|
40
|
+
)
|
|
41
|
+
result = await self._store.create_room(room)
|
|
42
|
+
await self._fire_lifecycle_hook(
|
|
43
|
+
room.id,
|
|
44
|
+
HookTrigger.ON_ROOM_CREATED,
|
|
45
|
+
EventType.SYSTEM,
|
|
46
|
+
code="room_created",
|
|
47
|
+
message=f"Room {room.id} created",
|
|
48
|
+
data={"room_id": room.id},
|
|
49
|
+
)
|
|
50
|
+
await self._emit_framework_event(
|
|
51
|
+
"room_created", room_id=room.id, data={"room_id": room.id}
|
|
52
|
+
)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
async def get_room(self, room_id: str) -> Room:
|
|
56
|
+
"""Get a room by ID. Raises RoomNotFoundError if missing."""
|
|
57
|
+
room = await self._store.get_room(room_id)
|
|
58
|
+
if room is None:
|
|
59
|
+
from roomkit.core.framework import RoomNotFoundError
|
|
60
|
+
|
|
61
|
+
raise RoomNotFoundError(f"Room {room_id} not found")
|
|
62
|
+
return room
|
|
63
|
+
|
|
64
|
+
async def close_room(self, room_id: str) -> Room:
|
|
65
|
+
"""Close a room."""
|
|
66
|
+
async with self._lock_manager.locked(room_id):
|
|
67
|
+
room = await self.get_room(room_id)
|
|
68
|
+
room = room.model_copy(
|
|
69
|
+
update={"status": RoomStatus.CLOSED, "closed_at": datetime.now(UTC)}
|
|
70
|
+
)
|
|
71
|
+
result = await self._store.update_room(room)
|
|
72
|
+
await self._fire_lifecycle_hook(
|
|
73
|
+
room_id,
|
|
74
|
+
HookTrigger.ON_ROOM_CLOSED,
|
|
75
|
+
EventType.SYSTEM,
|
|
76
|
+
code="room_closed",
|
|
77
|
+
message=f"Room {room_id} closed",
|
|
78
|
+
data={"room_id": room_id},
|
|
79
|
+
)
|
|
80
|
+
await self._emit_framework_event(
|
|
81
|
+
"room_closed", room_id=room_id, data={"room_id": room_id}
|
|
82
|
+
)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
async def check_room_timers(self, room_id: str) -> Room:
|
|
86
|
+
"""Check and apply timer-based transitions for a single room.
|
|
87
|
+
|
|
88
|
+
Returns the room (possibly transitioned to PAUSED or CLOSED).
|
|
89
|
+
"""
|
|
90
|
+
async with self._lock_manager.locked(room_id):
|
|
91
|
+
room = await self.get_room(room_id)
|
|
92
|
+
|
|
93
|
+
if room.status in (RoomStatus.CLOSED, RoomStatus.ARCHIVED):
|
|
94
|
+
return room
|
|
95
|
+
|
|
96
|
+
timers = room.timers
|
|
97
|
+
if timers.last_activity_at is None:
|
|
98
|
+
return room
|
|
99
|
+
|
|
100
|
+
elapsed = (datetime.now(UTC) - timers.last_activity_at).total_seconds()
|
|
101
|
+
|
|
102
|
+
# Check closed threshold first (supersedes pause)
|
|
103
|
+
if timers.closed_after_seconds is not None and elapsed > timers.closed_after_seconds:
|
|
104
|
+
if room.status != RoomStatus.CLOSED:
|
|
105
|
+
room = room.model_copy(
|
|
106
|
+
update={"status": RoomStatus.CLOSED, "closed_at": datetime.now(UTC)}
|
|
107
|
+
)
|
|
108
|
+
await self._store.update_room(room)
|
|
109
|
+
await self._emit_system_event(
|
|
110
|
+
room_id,
|
|
111
|
+
EventType.SYSTEM,
|
|
112
|
+
code="room_closed_by_timer",
|
|
113
|
+
message=f"Room {room_id} closed after {elapsed:.0f}s inactivity",
|
|
114
|
+
data={
|
|
115
|
+
"elapsed_seconds": elapsed,
|
|
116
|
+
"threshold": timers.closed_after_seconds,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
await self._fire_lifecycle_hook(
|
|
120
|
+
room_id,
|
|
121
|
+
HookTrigger.ON_ROOM_CLOSED,
|
|
122
|
+
EventType.SYSTEM,
|
|
123
|
+
code="room_closed_by_timer",
|
|
124
|
+
message=f"Room {room_id} closed by timer",
|
|
125
|
+
data={"elapsed_seconds": elapsed},
|
|
126
|
+
)
|
|
127
|
+
await self._emit_framework_event(
|
|
128
|
+
"room_closed", room_id=room_id, data={"reason": "timer"}
|
|
129
|
+
)
|
|
130
|
+
return room
|
|
131
|
+
|
|
132
|
+
# Check pause threshold (only for ACTIVE rooms)
|
|
133
|
+
if (
|
|
134
|
+
room.status == RoomStatus.ACTIVE
|
|
135
|
+
and timers.inactive_after_seconds is not None
|
|
136
|
+
and elapsed > timers.inactive_after_seconds
|
|
137
|
+
):
|
|
138
|
+
room = room.model_copy(
|
|
139
|
+
update={"status": RoomStatus.PAUSED, "updated_at": datetime.now(UTC)}
|
|
140
|
+
)
|
|
141
|
+
await self._store.update_room(room)
|
|
142
|
+
await self._emit_system_event(
|
|
143
|
+
room_id,
|
|
144
|
+
EventType.SYSTEM,
|
|
145
|
+
code="room_paused_by_timer",
|
|
146
|
+
message=f"Room {room_id} paused after {elapsed:.0f}s inactivity",
|
|
147
|
+
data={"elapsed_seconds": elapsed, "threshold": timers.inactive_after_seconds},
|
|
148
|
+
)
|
|
149
|
+
await self._fire_lifecycle_hook(
|
|
150
|
+
room_id,
|
|
151
|
+
HookTrigger.ON_ROOM_PAUSED,
|
|
152
|
+
EventType.SYSTEM,
|
|
153
|
+
code="room_paused_by_timer",
|
|
154
|
+
message=f"Room {room_id} paused by timer",
|
|
155
|
+
data={"elapsed_seconds": elapsed},
|
|
156
|
+
)
|
|
157
|
+
await self._emit_framework_event(
|
|
158
|
+
"room_paused", room_id=room_id, data={"reason": "timer"}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return room
|
|
162
|
+
|
|
163
|
+
async def check_all_timers(self) -> list[Room]:
|
|
164
|
+
"""Check timers on all active/paused rooms. Returns rooms that transitioned."""
|
|
165
|
+
transitioned: list[Room] = []
|
|
166
|
+
for status in (RoomStatus.ACTIVE, RoomStatus.PAUSED):
|
|
167
|
+
rooms = await self._store.find_rooms(status=status.value)
|
|
168
|
+
for room in rooms:
|
|
169
|
+
old_status = room.status
|
|
170
|
+
updated = await self.check_room_timers(room.id)
|
|
171
|
+
if updated.status != old_status:
|
|
172
|
+
transitioned.append(updated)
|
|
173
|
+
return transitioned
|
|
174
|
+
|
|
175
|
+
async def update_room_metadata(self, room_id: str, metadata: dict[str, Any]) -> Room:
|
|
176
|
+
"""Update room metadata."""
|
|
177
|
+
async with self._lock_manager.locked(room_id):
|
|
178
|
+
room = await self.get_room(room_id)
|
|
179
|
+
room = room.model_copy(
|
|
180
|
+
update={"metadata": {**room.metadata, **metadata}, "updated_at": datetime.now(UTC)}
|
|
181
|
+
)
|
|
182
|
+
return await self._store.update_room(room)
|
|
183
|
+
|
|
184
|
+
async def ensure_participant(
|
|
185
|
+
self,
|
|
186
|
+
room_id: str,
|
|
187
|
+
channel_id: str,
|
|
188
|
+
participant_id: str,
|
|
189
|
+
display_name: str | None = None,
|
|
190
|
+
) -> Participant:
|
|
191
|
+
"""Get an existing participant or create one."""
|
|
192
|
+
existing = await self._store.get_participant(room_id, participant_id)
|
|
193
|
+
if existing:
|
|
194
|
+
return existing
|
|
195
|
+
participant = Participant(
|
|
196
|
+
id=participant_id,
|
|
197
|
+
room_id=room_id,
|
|
198
|
+
channel_id=channel_id,
|
|
199
|
+
display_name=display_name,
|
|
200
|
+
)
|
|
201
|
+
return await self._store.add_participant(participant)
|
|
202
|
+
|
|
203
|
+
async def resolve_participant(
|
|
204
|
+
self,
|
|
205
|
+
room_id: str,
|
|
206
|
+
participant_id: str,
|
|
207
|
+
identity_id: str,
|
|
208
|
+
resolved_by: str = "manual",
|
|
209
|
+
) -> Participant:
|
|
210
|
+
"""Resolve a pending participant to a known identity (RFC 7.4).
|
|
211
|
+
|
|
212
|
+
Called by an advisor or automated process when a pending/ambiguous
|
|
213
|
+
participant has been identified.
|
|
214
|
+
"""
|
|
215
|
+
async with self._lock_manager.locked(room_id):
|
|
216
|
+
participant = await self._store.get_participant(room_id, participant_id)
|
|
217
|
+
if participant is None:
|
|
218
|
+
from roomkit.core.framework import ParticipantNotFoundError
|
|
219
|
+
|
|
220
|
+
raise ParticipantNotFoundError(
|
|
221
|
+
f"Participant {participant_id} not found in room {room_id}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
identity = await self._store.get_identity(identity_id)
|
|
225
|
+
if identity is None:
|
|
226
|
+
from roomkit.core.framework import IdentityNotFoundError
|
|
227
|
+
|
|
228
|
+
raise IdentityNotFoundError(f"Identity {identity_id} not found")
|
|
229
|
+
|
|
230
|
+
# Update participant fields
|
|
231
|
+
participant = participant.model_copy(
|
|
232
|
+
update={
|
|
233
|
+
"identification": IdentificationStatus.IDENTIFIED,
|
|
234
|
+
"identity_id": identity_id,
|
|
235
|
+
"resolved_at": datetime.now(UTC),
|
|
236
|
+
"resolved_by": resolved_by,
|
|
237
|
+
"candidates": None,
|
|
238
|
+
"display_name": identity.display_name or participant.display_name,
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
await self._store.update_participant(participant)
|
|
242
|
+
|
|
243
|
+
# Update binding if present
|
|
244
|
+
binding = await self._store.get_binding(room_id, participant.channel_id)
|
|
245
|
+
if binding:
|
|
246
|
+
binding = binding.model_copy(update={"participant_id": identity_id})
|
|
247
|
+
await self._store.update_binding(binding)
|
|
248
|
+
|
|
249
|
+
# Emit system event
|
|
250
|
+
await self._emit_system_event(
|
|
251
|
+
room_id,
|
|
252
|
+
EventType.PARTICIPANT_IDENTIFIED,
|
|
253
|
+
code="participant_identified",
|
|
254
|
+
message=f"Participant {participant_id} identified as {identity.display_name}",
|
|
255
|
+
data={
|
|
256
|
+
"participant_id": participant_id,
|
|
257
|
+
"identity_id": identity_id,
|
|
258
|
+
"resolved_by": resolved_by,
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Fire lifecycle hook
|
|
263
|
+
await self._fire_lifecycle_hook(
|
|
264
|
+
room_id,
|
|
265
|
+
HookTrigger.ON_PARTICIPANT_IDENTIFIED,
|
|
266
|
+
EventType.PARTICIPANT_IDENTIFIED,
|
|
267
|
+
code="participant_identified",
|
|
268
|
+
message="Participant identified",
|
|
269
|
+
data={
|
|
270
|
+
"participant_id": participant_id,
|
|
271
|
+
"identity_id": identity_id,
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return participant
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Circuit breaker for provider fault isolation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CircuitBreaker:
|
|
9
|
+
"""Simple circuit breaker (closed → open → half-open → closed).
|
|
10
|
+
|
|
11
|
+
* **Closed** — requests flow normally.
|
|
12
|
+
* **Open** — all requests fail immediately (after *failure_threshold* consecutive failures).
|
|
13
|
+
* **Half-open** — after *recovery_timeout* seconds, allow one probe request.
|
|
14
|
+
|
|
15
|
+
**Concurrency note:** This implementation relies on the CPython single-threaded
|
|
16
|
+
asyncio model. State mutations in ``allow_request``, ``record_success``, and
|
|
17
|
+
``record_failure`` contain no ``await`` between the check and the set, making
|
|
18
|
+
them atomic within a single event-loop iteration. No additional locking is
|
|
19
|
+
required as long as all callers run in the same event loop.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
failure_threshold: int = 5,
|
|
25
|
+
recovery_timeout: float = 60.0,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._failure_threshold = failure_threshold
|
|
28
|
+
self._recovery_timeout = recovery_timeout
|
|
29
|
+
self._failure_count: int = 0
|
|
30
|
+
self._opened_at: float | None = None
|
|
31
|
+
self._half_open_probe_sent: bool = False
|
|
32
|
+
|
|
33
|
+
# -- State queries --
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_open(self) -> bool:
|
|
37
|
+
"""True when the breaker is fully open (not half-open)."""
|
|
38
|
+
if self._opened_at is None:
|
|
39
|
+
return False
|
|
40
|
+
elapsed = time.monotonic() - self._opened_at
|
|
41
|
+
return elapsed < self._recovery_timeout
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_half_open(self) -> bool:
|
|
45
|
+
"""True when recovery timeout has elapsed and a probe is allowed."""
|
|
46
|
+
if self._opened_at is None:
|
|
47
|
+
return False
|
|
48
|
+
return (time.monotonic() - self._opened_at) >= self._recovery_timeout
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_closed(self) -> bool:
|
|
52
|
+
return self._opened_at is None
|
|
53
|
+
|
|
54
|
+
def allow_request(self) -> bool:
|
|
55
|
+
"""Return True if a request should be attempted."""
|
|
56
|
+
if self.is_closed:
|
|
57
|
+
return True
|
|
58
|
+
if self.is_half_open and not self._half_open_probe_sent:
|
|
59
|
+
self._half_open_probe_sent = True
|
|
60
|
+
return True
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
# -- Recording --
|
|
64
|
+
|
|
65
|
+
def record_success(self) -> None:
|
|
66
|
+
"""Record a successful call — resets the breaker to closed."""
|
|
67
|
+
self._failure_count = 0
|
|
68
|
+
self._opened_at = None
|
|
69
|
+
self._half_open_probe_sent = False
|
|
70
|
+
|
|
71
|
+
def record_failure(self) -> None:
|
|
72
|
+
"""Record a failed call — may trip the breaker open."""
|
|
73
|
+
self._failure_count += 1
|
|
74
|
+
if self._failure_count >= self._failure_threshold:
|
|
75
|
+
self._opened_at = time.monotonic()
|
|
76
|
+
self._half_open_probe_sent = False
|
|
77
|
+
|
|
78
|
+
# -- Reset --
|
|
79
|
+
|
|
80
|
+
def reset(self) -> None:
|
|
81
|
+
"""Manually close the breaker."""
|
|
82
|
+
self._failure_count = 0
|
|
83
|
+
self._opened_at = None
|
|
84
|
+
self._half_open_probe_sent = False
|