zettcode-im 0.4.2__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.
@@ -0,0 +1,21 @@
1
+ """The line between a chat app and one ZettCode daemon.
2
+
3
+ A chat message becomes a prompt for a background agent, and the agent's answer goes back to the chat it
4
+ came from. The parts:
5
+
6
+ - ``state`` — what has to survive a restart: the IM login handshake, its credentials, the receive cursor, the
7
+ id of the session the daemon runs, and the reply context of each chat. The IM client keeps none of this
8
+ itself.
9
+ - ``guard`` — the one daemon the bridge owns: started on demand, started again when it goes away, left alone
10
+ when it is running.
11
+ - ``link`` — the bridge's own session on the broker, so the daemon reads the message as a reader's and answers
12
+ the bridge, which forwards the answer.
13
+ - ``bridge`` — the loop that ties them together, and everything that can go wrong in it.
14
+
15
+ ``cli`` and ``worker`` make a command of it: ``zettcode-weixin`` logs in, then hands the connection to a
16
+ background process that runs the bridge.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ __all__: list[str] = []
zettcode/im/bridge.py ADDED
@@ -0,0 +1,368 @@
1
+ """One chat message becomes one turn of the daemon, and the answer goes back where it came from.
2
+
3
+ The loop has two halves on purpose. Receiving is a long poll that must keep running — a turn can take minutes,
4
+ and a poll that stops while the agent thinks is a message the platform has to keep for us. So one task takes
5
+ messages off the platform as fast as they arrive, and one task runs them, one at a time, because there is one
6
+ daemon and one conversation behind it.
7
+
8
+ Everything that can go wrong has a sentence for the reader, because a chat app has no stack trace to show:
9
+
10
+ - the daemon is down and cannot start: how long until the next attempt, or what the start said;
11
+ - the broker is out of reach or the daemon refused the message;
12
+ - the daemon took it but has not answered: the answer is not lost, and the next message starts a fresh turn;
13
+ - the text is too long for one message: it is split, and the split points are on line breaks where it can;
14
+ - the same event delivered twice (a restart mid-batch): answered once;
15
+ - a sender who is not allowed to drive the agent: refused, with the id to allow;
16
+ - pictures: forwarded with the text they came with, and the ones that cannot go — too many, too large, a format the
17
+ broker does not take — named back to the sender instead of lost quietly. A daemon whose model reads no pictures
18
+ refuses the message itself, and that refusal is what the chat is told.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import hashlib
25
+ import logging
26
+ from collections.abc import Callable
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ from .guard import DaemonGuard, DaemonUnavailable
31
+ from .link import LinkError, SessionLink
32
+ from .state import BridgeState
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ #: Characters per outgoing message. Chat apps are generous but not unlimited, and a long answer that fails to
37
+ #: send is lost, so it is split below the limit rather than at it.
38
+ CHUNK = 3000
39
+
40
+ #: How often the daemon is looked at when no message is arriving, so a daemon that died is back before somebody
41
+ #: writes rather than after.
42
+ HEALTH_EVERY = 20.0
43
+
44
+ #: Seconds to wait before polling again after the platform itself failed.
45
+ POLL_BACKOFF = 5.0
46
+
47
+ #: Seconds one poll may stay out before it is dropped and asked again (see :meth:`Bridge._receive`).
48
+ POLL_TIMEOUT = 120.0
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class BridgeSettings:
53
+ """What the bridge allows and how it behaves.
54
+
55
+ Args:
56
+ allow: The user ids that may drive the agent. Empty means nobody: the agent has tools, and a bridge that
57
+ answers a stranger is a shell a stranger can reach.
58
+ allow_all: Accept every sender. For a bot nobody else can find; logged loudly when the bridge starts.
59
+ chunk: Characters per outgoing message.
60
+ health_every: Seconds between looks at the daemon when nothing is arriving.
61
+ """
62
+
63
+ allow: frozenset[str] = frozenset()
64
+ allow_all: bool = False
65
+ chunk: int = CHUNK
66
+ health_every: float = HEALTH_EVERY
67
+ #: Seconds a single poll may stay out before it is abandoned and asked again. The platform's long poll is under
68
+ #: a minute; this is the width of the slack, not the poll's own timeout.
69
+ poll_timeout: float = POLL_TIMEOUT
70
+
71
+ def permits(self, user_id: str) -> bool:
72
+ """Return whether this sender may drive the agent."""
73
+ return self.allow_all or user_id in self.allow
74
+
75
+
76
+ @dataclass
77
+ class Bridge:
78
+ """The chat side, the daemon, and the loop between them.
79
+
80
+ Args:
81
+ im: The platform client, already logged in (``agim``'s ``IMClient``).
82
+ guard: The one daemon.
83
+ link: The bridge's session on the broker.
84
+ state: What has to survive a restart.
85
+ path: Where ``state`` is written.
86
+ settings: What it accepts and how it behaves.
87
+ error: Where a change worth a reader's eye goes; the worker hands it the log.
88
+ """
89
+
90
+ im: Any
91
+ guard: DaemonGuard
92
+ link: SessionLink
93
+ state: BridgeState
94
+ path: Any
95
+ settings: BridgeSettings = field(default_factory=BridgeSettings)
96
+ error: Callable[[str], None] = logger.warning
97
+ _incoming: asyncio.Queue = field(default_factory=asyncio.Queue, init=False, repr=False)
98
+ _stopped: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
99
+ #: Messages whose answer could not be delivered, and how many times it was tried.
100
+ _undelivered: dict[str, int] = field(default_factory=dict, init=False, repr=False)
101
+
102
+ # -- the run --------------------------------------------------------------------------------------
103
+ async def run(self) -> None:
104
+ """Answer messages until :meth:`stop` is called or the platform goes away.
105
+
106
+ A platform that fails to poll is retried after a pause; it is not a reason to leave, because a long poll
107
+ that times out is normal and the next one usually has the message.
108
+ """
109
+ if not self.settings.allow and not self.settings.allow_all:
110
+ self.error("no sender is allowed: every message will be refused until --allow names one")
111
+ tasks = [
112
+ asyncio.create_task(self._receive()),
113
+ asyncio.create_task(self._answer()),
114
+ asyncio.create_task(self._watch()),
115
+ ]
116
+ try:
117
+ await self._stopped.wait()
118
+ finally:
119
+ for task in tasks:
120
+ task.cancel()
121
+ for task in tasks:
122
+ try:
123
+ await task
124
+ except (asyncio.CancelledError, Exception): # noqa: BLE001 - the run is ending anyway
125
+ pass
126
+
127
+ def stop(self) -> None:
128
+ """Leave after the message being answered."""
129
+ self._stopped.set()
130
+
131
+ # -- the two halves -------------------------------------------------------------------------------
132
+ async def _receive(self) -> None:
133
+ """Take messages off the platform and queue them; never run a turn here.
134
+
135
+ The poll is given a deadline. The platform's long poll is under a minute, so a call that is still out after
136
+ ``poll_timeout`` is not waiting for a message — it is a connection the network quietly dropped, and a bridge
137
+ that keeps waiting on it is a bridge that never speaks again. The deadline turns that into one more poll.
138
+ """
139
+ while not self._stopped.is_set():
140
+ try:
141
+ result = await asyncio.wait_for(
142
+ self.im.receive(self.state.cursor or None), timeout=max(1.0, self.settings.poll_timeout)
143
+ )
144
+ except asyncio.TimeoutError:
145
+ # The usual end of a quiet chat, not a fault: the IM client's own poll never returns without a
146
+ # message, so this fires every `poll_timeout` seconds for as long as nobody writes.
147
+ logger.debug("no message in %d seconds; polling again", int(self.settings.poll_timeout))
148
+ continue
149
+ except asyncio.CancelledError:
150
+ raise
151
+ except Exception as error: # noqa: BLE001 - a poll that fails is retried, not fatal
152
+ self.error(f"receiving failed: {error}")
153
+ await asyncio.sleep(POLL_BACKOFF)
154
+ continue
155
+ message = result.message
156
+ # Every delivery is queued, duplicates included: the state is only written when an answer is done, so a
157
+ # batch handed over twice can be in the queue twice, and it is `_turn` that sees it there.
158
+ self._incoming.put_nowait((message, result.cursor))
159
+
160
+ async def _answer(self) -> None:
161
+ """Run queued messages one at a time: one daemon, one conversation, one turn."""
162
+ while not self._stopped.is_set():
163
+ message, cursor = await self._incoming.get()
164
+ try:
165
+ await self._turn(message, cursor)
166
+ except asyncio.CancelledError:
167
+ raise
168
+ except Exception as error: # noqa: BLE001 - one bad message must not end the bridge
169
+ self.error(f"answering {message.event_id} failed: {error}")
170
+
171
+ async def _watch(self) -> None:
172
+ """Bring the daemon back when it goes away, without a message having to arrive first."""
173
+ while not self._stopped.is_set():
174
+ await asyncio.sleep(max(1.0, self.settings.health_every))
175
+ if self._stopped.is_set() or not self.state.session_id:
176
+ continue
177
+ try:
178
+ await self.guard.ensure()
179
+ except DaemonUnavailable as error:
180
+ # Said once per failure at most: the guard already waited before this attempt, and a log line per
181
+ # twenty seconds about a broken model key helps nobody.
182
+ self.error(f"the daemon is not up: {error}")
183
+
184
+ # -- one message ----------------------------------------------------------------------------------
185
+ async def _turn(self, message: Any, cursor: str) -> None:
186
+ """Answer one inbound message, and remember that it was answered."""
187
+ identity = _identity(message)
188
+ if self.state.answered(identity):
189
+ # The queue is a moment behind the state: a batch handed over twice may be queued twice before the
190
+ # first answer is written. The state is the word, not the queue — but the cursor still moves, or the
191
+ # next poll would ask for this message again.
192
+ self.state.cursor = cursor or self.state.cursor
193
+ self._save()
194
+ return
195
+ chat = str(message.chat_id)
196
+ # One line per message, before anything can fail: without it a turn that worked and a message that never
197
+ # arrived look the same in the log, which is the worst thing to be unsure about.
198
+ self.error(
199
+ f"got {message.event_id} from {message.user_id} in {chat}: "
200
+ f"{len(str(message.text))} characters, {len(getattr(message, 'media', ()) or ())} attachment(s)"
201
+ )
202
+ if not self.settings.permits(str(message.user_id)):
203
+ self.error(f"refused {message.event_id}: {message.user_id} is not in the allowed senders")
204
+ await self._say(
205
+ chat, message, f"this chat is not allowed to use the agent. The sender id is {message.user_id}"
206
+ )
207
+ self._done(message, cursor)
208
+ return
209
+ prompt = _prompt(message)
210
+ pictures, notes = _pictures(message)
211
+ if notes:
212
+ prompt = "\n".join([prompt, *(f"({note})" for note in notes)])
213
+ if not message.text.strip() and not pictures and not notes:
214
+ # Nothing to read and nobody to tell: no turn, no cost.
215
+ await self._say(chat, message, "I cannot read that kind of message yet; send words or a picture.")
216
+ self._done(message, cursor)
217
+ return
218
+ try:
219
+ session = await self.guard.ensure()
220
+ except DaemonUnavailable as error:
221
+ await self._say(chat, message, self._unavailable(str(error)))
222
+ self._done(message, cursor)
223
+ return
224
+ self.link.status("working")
225
+ try:
226
+ answer = await self.link.ask(session, prompt, images=pictures)
227
+ except LinkError as error:
228
+ self.error(f"the daemon did not answer {message.event_id}: {error}")
229
+ await self._say(chat, message, f"I could not ask the agent: {error}")
230
+ self._done(message, cursor)
231
+ return
232
+ finally:
233
+ self.link.status("idle")
234
+ if not answer.strip():
235
+ answer = "the agent finished without an answer to send back."
236
+ if await self._say(chat, message, answer):
237
+ self.error(f"answered {message.event_id}: {len(answer)} characters")
238
+ self._done(message, cursor)
239
+ return
240
+ # The answer exists and the chat will not take it. Answering the same message again would run the turn
241
+ # again — and again, for as long as the platform keeps refusing — so the message is behind us after a
242
+ # second attempt, and the log is where the reason is.
243
+ attempts = self._undelivered.get(identity, 0) + 1
244
+ self._undelivered[identity] = attempts
245
+ if attempts >= 2:
246
+ self._undelivered.pop(identity, None)
247
+ self.error(f"giving up on delivering the answer to {message.event_id}: {attempts} attempts")
248
+ self._done(message, cursor)
249
+
250
+ def _unavailable(self, said: str) -> str:
251
+ """Return what a chat is told when the daemon is not there."""
252
+ wait = self.guard.wait_left()
253
+ if wait > 0:
254
+ return f"the agent is not running: {said}. I will try again in {int(wait) + 1} seconds."
255
+ return f"the agent could not be started: {said}"
256
+
257
+ async def _say(self, chat: str, message: Any, text: str) -> bool:
258
+ """Send ``text`` to the chat in pieces; ``False`` when a piece did not go out.
259
+
260
+ The reply context is the one this message carried, or the last one this chat had: a message that arrives
261
+ without one (a refused attachment, for instance) should still be answered in the conversation it belongs to.
262
+ """
263
+ context = getattr(message, "reply_token", None) or self.state.reply_token(chat)
264
+ for piece in split(text, self.settings.chunk):
265
+ try:
266
+ await self.im.send(chat, piece, context_token=context)
267
+ except Exception as error: # noqa: BLE001 - the chat is gone or the platform refused it
268
+ self.error(f"sending to {chat} failed: {error}")
269
+ return False
270
+ return True
271
+
272
+ def _done(self, message: Any, cursor: str) -> None:
273
+ """Record the answer: the message is behind us, and the next poll starts after it."""
274
+ self.state.remember(
275
+ _identity(message), cursor=cursor or "", chat_id=str(message.chat_id), reply_token=message.reply_token
276
+ )
277
+ self._save()
278
+
279
+ def _save(self) -> None:
280
+ """Write the state, and say so if it cannot be written: a lost cursor is a re-answered batch."""
281
+ try:
282
+ self.state.save_runtime(self.path)
283
+ except OSError as error:
284
+ self.error(f"the state at {self.path} could not be written: {error}")
285
+
286
+
287
+ def _identity(message: Any) -> str:
288
+ """Return the key a message is remembered by: the chat, the platform's id for it, and what it said.
289
+
290
+ The platform's own id cannot stand alone. When a message carries no id the IM client falls back to a sequence
291
+ number — one that starts at 1 in every conversation and again after every login — so ``"1"`` names a different
292
+ message tomorrow. Remembering it alone made a new message look answered, and the bridge dropped it without a
293
+ word. The chat and a short digest of the text make a collision need the same sequence number, in the same chat,
294
+ with the same words, which is what a genuine re-delivery of one message is.
295
+ """
296
+ digest = hashlib.sha1(str(message.text).encode("utf-8")).hexdigest()[:10]
297
+ return f"{message.chat_id}|{message.event_id}|{digest}"
298
+
299
+
300
+ def _prompt(message: Any) -> str:
301
+ """Return what the daemon is asked, with who said it; the pictures are gathered separately."""
302
+ parts = [f"[chat {message.chat_id} from {message.user_id}]"]
303
+ if message.text.strip():
304
+ parts.append(message.text.strip())
305
+ return "\n".join(parts)
306
+
307
+
308
+ def _pictures(message: Any) -> tuple[list[Any], list[str]]:
309
+ """Return the pictures that can travel with the prompt, and a note for each one that cannot.
310
+
311
+ The broker takes four pictures, four mebibytes each and eight together, in four formats. The limit is enforced
312
+ there, so a message over it would be refused whole — text and all — which is why the count happens here: the
313
+ text still goes, and the sender is told what stayed behind.
314
+ """
315
+ from zettcode.broker.protocol import IMAGE_TYPES, MAX_IMAGE_BYTES, MAX_IMAGES, MAX_IMAGES_BYTES, ImageData
316
+
317
+ media = list(getattr(message, "media", ()) or ())
318
+ pictures: list[ImageData] = []
319
+ notes: list[str] = []
320
+ total = 0
321
+ for item in media:
322
+ kind = str(getattr(item, "kind", "file"))
323
+ if kind != "image":
324
+ notes.append(f"1 {kind} not forwarded: only pictures and text reach the agent")
325
+ continue
326
+ media_type = str(getattr(item, "media_type", ""))
327
+ data = bytes(getattr(item, "data", b""))
328
+ if media_type not in IMAGE_TYPES:
329
+ notes.append(f"1 picture not forwarded: {media_type or 'unknown type'} is not one the agent takes")
330
+ continue
331
+ if len(data) > MAX_IMAGE_BYTES:
332
+ notes.append(f"1 picture not forwarded: {len(data) // (1 << 20)} MiB is over the 4 MiB limit")
333
+ continue
334
+ if len(pictures) >= MAX_IMAGES:
335
+ notes.append(f"1 picture not forwarded: at most {MAX_IMAGES} go with one message")
336
+ continue
337
+ if total + len(data) > MAX_IMAGES_BYTES:
338
+ notes.append("1 picture not forwarded: the pictures together are over 8 MiB")
339
+ continue
340
+ pictures.append(ImageData(media_type, data))
341
+ total += len(data)
342
+ refused = list(getattr(message, "rejected_media", ()) or ())
343
+ if refused:
344
+ notes.append(f"{len(refused)} attachment(s) the platform refused: too large")
345
+ return pictures, notes
346
+
347
+
348
+ def split(text: str, limit: int) -> list[str]:
349
+ """Split ``text`` into pieces of at most ``limit`` characters, on line breaks where it can.
350
+
351
+ A single line longer than the limit is cut at the limit, which is the only place a reader may see a word
352
+ broken in two; everything else starts a piece at a line break.
353
+ """
354
+ if limit <= 0:
355
+ return [text]
356
+ pieces: list[str] = []
357
+ rest = text
358
+ while len(rest) > limit:
359
+ cut = rest.rfind("\n", 0, limit)
360
+ if cut <= 0:
361
+ cut = limit
362
+ pieces.append(rest[:cut])
363
+ rest = rest[cut:].lstrip("\n")
364
+ pieces.append(rest)
365
+ return [piece for piece in pieces if piece]
366
+
367
+
368
+ __all__ = ["Bridge", "BridgeSettings", "CHUNK", "HEALTH_EVERY", "POLL_BACKOFF", "split"]
zettcode/im/guard.py ADDED
@@ -0,0 +1,154 @@
1
+ """The one daemon the bridge owns: started when it is needed, again when it goes away, never twice.
2
+
3
+ The daemon is a background ZettCode session with a fixed id, so every message a chat sends is one more turn of
4
+ the same conversation. The id is learned the first time a daemon is started and then kept: a start afterwards
5
+ resumes that session.
6
+
7
+ Two things here are about failing well. A start that fails is not retried on the next message as if nothing had
8
+ happened — the wait between attempts grows, and while it is running the bridge says how long it will be rather
9
+ than starting a second process that would fight the first for the session lock. And a lock somebody else holds is
10
+ not a failure to retry at all: the daemon would only be refused again, so the reader is told once and the bridge
11
+ carries on answering with what it has.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from .state import BridgeState
22
+
23
+ #: How long to wait after the first failed start, and the most it grows to. A daemon that cannot start because
24
+ #: its model credentials are wrong will not start in a minute either, and the bridge should not spend the day
25
+ #: spawning processes to find that out.
26
+ FIRST_WAIT = 5.0
27
+ MAX_WAIT = 300.0
28
+
29
+ #: What a daemon record says about a process that is there. Anything else — ``dead``, or no record at all — is a
30
+ #: daemon the bridge may start.
31
+ ALIVE = frozenset({"starting", "idle", "working", "stopping"})
32
+
33
+
34
+ class DaemonUnavailable(RuntimeError):
35
+ """The daemon could not be started, and the message says what to tell the reader."""
36
+
37
+ def __init__(self, message: str, *, retry_in: float = 0.0) -> None:
38
+ super().__init__(message)
39
+ self.retry_in = retry_in
40
+
41
+
42
+ @dataclass
43
+ class DaemonGuard:
44
+ """Keeps one daemon reachable, and remembers the session it runs.
45
+
46
+ Args:
47
+ workspace: The directory the daemon works in.
48
+ state: Where the session id lives between runs; the guard writes it when it learns one.
49
+ model: The model by name, or ``None`` for the first configured.
50
+ effort: The reasoning level, or ``None`` for the model's own.
51
+ permission: The shell level the daemon gets: ``deny``, ``safe`` or ``full``.
52
+ name: The label recorded beside the daemon, so a reader finds it in ``zettcode daemon list``.
53
+ waits: The waits between failed starts, in order; the last one repeats.
54
+ """
55
+
56
+ workspace: Path
57
+ state: BridgeState
58
+ model: str | None = None
59
+ effort: str | None = None
60
+ permission: str = "safe"
61
+ name: str = "chat bridge"
62
+ waits: tuple[float, ...] = ()
63
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
64
+ _failures: int = field(default=0, init=False, repr=False)
65
+ _next_try: float = field(default=0.0, init=False, repr=False)
66
+ _said: str = field(default="", init=False, repr=False)
67
+
68
+ # -- what the bridge asks -------------------------------------------------------------------------
69
+ async def ensure(self) -> str:
70
+ """Return the session the daemon runs, starting it when it is not there.
71
+
72
+ Raises:
73
+ DaemonUnavailable: The last attempt failed and its wait has not passed, or this attempt did.
74
+ """
75
+ async with self._lock:
76
+ session = str(getattr(self.state, "session_id", ""))
77
+ running = await asyncio.to_thread(self._running, session)
78
+ if running:
79
+ self._ok()
80
+ return session
81
+ wait = self._next_try - time.monotonic()
82
+ if wait > 0:
83
+ raise DaemonUnavailable(self._said or "the daemon is not up yet", retry_in=wait)
84
+ try:
85
+ session = await self._start(session or None)
86
+ except Exception as error: # noqa: BLE001 - every way a start fails is the reader's sentence
87
+ wait = self._failed(str(error) or error.__class__.__name__)
88
+ raise DaemonUnavailable(str(error) or "the daemon did not start", retry_in=wait) from error
89
+ self._ok()
90
+ self.state.session_id = session
91
+ self.state.save()
92
+ return session
93
+
94
+ def forget(self) -> None:
95
+ """Drop the failure count, so the next message tries at once."""
96
+ self._ok()
97
+
98
+ def wait_left(self) -> float:
99
+ """Return how many seconds the next attempt has to wait, or zero when it may try now."""
100
+ return max(0.0, self._next_try - time.monotonic())
101
+
102
+ @property
103
+ def failures(self) -> int:
104
+ """How many starts have failed in a row."""
105
+ return self._failures
106
+
107
+ # -- what it does ---------------------------------------------------------------------------------
108
+ @staticmethod
109
+ def _running(session: str) -> bool:
110
+ """Return whether a daemon for ``session`` is up, read from its record."""
111
+ if not session:
112
+ return False
113
+ from zettcode.sdk.daemons import list_daemons
114
+
115
+ for info in list_daemons():
116
+ if info.session_id == session:
117
+ return str(info.status) in ALIVE
118
+ return False
119
+
120
+ async def _start(self, session: str | None) -> str:
121
+ """Start a daemon, resuming ``session`` when there is one, and return its session id."""
122
+ from zettcode.sdk.daemons import spawn_daemon
123
+
124
+ info = await spawn_daemon(
125
+ Path(self.workspace),
126
+ model=self.model,
127
+ effort=self.effort,
128
+ resume=session,
129
+ name=self.name,
130
+ permission=self.permission,
131
+ )
132
+ return info.session_id
133
+
134
+ def _wait(self) -> float:
135
+ """Return how long the next attempt waits, and remember it: five seconds, doubling, up to five minutes."""
136
+ if self.waits:
137
+ return self.waits[min(self._failures, len(self.waits) - 1)]
138
+ return min(FIRST_WAIT * (2**self._failures), MAX_WAIT)
139
+
140
+ def _failed(self, said: str) -> float:
141
+ """Count a failed attempt, wait longer before the next, and return how long that is."""
142
+ self._failures += 1
143
+ wait = self._wait()
144
+ self._next_try = time.monotonic() + wait
145
+ self._said = said
146
+ return wait
147
+
148
+ def _ok(self) -> None:
149
+ self._failures = 0
150
+ self._next_try = 0.0
151
+ self._said = ""
152
+
153
+
154
+ __all__ = ["ALIVE", "DaemonGuard", "DaemonUnavailable", "FIRST_WAIT", "MAX_WAIT"]