devalerts 0.1.4__tar.gz → 0.1.5__tar.gz
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.
- {devalerts-0.1.4 → devalerts-0.1.5}/PKG-INFO +63 -16
- {devalerts-0.1.4 → devalerts-0.1.5}/README.md +62 -15
- {devalerts-0.1.4 → devalerts-0.1.5}/pyproject.toml +14 -3
- {devalerts-0.1.4 → devalerts-0.1.5}/src/devalerts/__init__.py +42 -9
- {devalerts-0.1.4 → devalerts-0.1.5}/src/devalerts/_alert.py +1 -1
- {devalerts-0.1.4 → devalerts-0.1.5}/src/devalerts/_store.py +11 -3
- {devalerts-0.1.4 → devalerts-0.1.5}/src/devalerts/_telegram.py +1 -1
- {devalerts-0.1.4 → devalerts-0.1.5}/src/devalerts/cli.py +3 -1
- {devalerts-0.1.4 → devalerts-0.1.5}/LICENSE +0 -0
- {devalerts-0.1.4 → devalerts-0.1.5}/src/devalerts/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devalerts
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token.
|
|
5
5
|
Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
|
|
6
6
|
Author: sslinNn
|
|
@@ -28,8 +28,12 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
# devalerts
|
|
29
29
|
|
|
30
30
|
[](https://pypi.org/project/devalerts/)
|
|
31
|
+
[](https://pypi.org/project/devalerts/)
|
|
31
32
|
[](https://pypi.org/project/devalerts/)
|
|
32
33
|
[](LICENSE)
|
|
34
|
+
[](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
|
|
35
|
+
[](https://github.com/astral-sh/ruff)
|
|
36
|
+
[](https://mypy-lang.org/)
|
|
33
37
|
|
|
34
38
|
[Русская версия](README.ru.md)
|
|
35
39
|
|
|
@@ -103,14 +107,7 @@ See what's grouped and what's currently rate-limited:
|
|
|
103
107
|
uv run devalerts dashboard
|
|
104
108
|
```
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
ID TYPE LOCATION LAST SEEN TOTAL STATUS
|
|
108
|
-
────────────────────────────────────────────────────────────────────────────────────
|
|
109
|
-
a1b2c3d4 ValueError app/orders.py:42 2m ago 14 ● limited
|
|
110
|
-
9f8e7d6c KeyError app/handlers/webhook.py:88 just now 1 ● sending
|
|
111
|
-
|
|
112
|
-
2 error groups, 1 currently rate-limited.
|
|
113
|
-
```
|
|
110
|
+

|
|
114
111
|
|
|
115
112
|
## Manually reporting a caught exception
|
|
116
113
|
|
|
@@ -151,11 +148,12 @@ Only exceptions that actually escape as server errors get reported — routing
|
|
|
151
148
|
404s and raised `HTTPException`s are already turned into responses by the
|
|
152
149
|
framework before the middleware sees them.
|
|
153
150
|
|
|
154
|
-
##
|
|
151
|
+
## Why not Sentry?
|
|
155
152
|
|
|
156
|
-
If you already run Sentry/Rollbar/etc., keep using it —
|
|
157
|
-
replacement.
|
|
158
|
-
|
|
153
|
+
If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
|
|
154
|
+
replacement. It's for the side project, internal tool, or small service that
|
|
155
|
+
doesn't have (and doesn't want) that infrastructure: no account to create, no
|
|
156
|
+
SDK to configure, no server to trust — just a bot token you already control.
|
|
159
157
|
|
|
160
158
|
| | devalerts | Sentry-style tracker |
|
|
161
159
|
|--------------------------|---------------------|-----------------------|
|
|
@@ -165,14 +163,63 @@ service that doesn't have (and doesn't want) that infrastructure yet:
|
|
|
165
163
|
| Grouping / rate limiting | yes, local SQLite | yes, server-side |
|
|
166
164
|
| Search, trends, releases | no | yes |
|
|
167
165
|
|
|
166
|
+
## FAQ
|
|
167
|
+
|
|
168
|
+
**Works with FastAPI / Starlette?**
|
|
169
|
+
Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
|
|
170
|
+
default excepthook never sees request errors.
|
|
171
|
+
|
|
172
|
+
**Works with Docker?**
|
|
173
|
+
Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
|
|
174
|
+
state file) is either writable inside the container or a mounted volume if
|
|
175
|
+
you want dedup state to survive restarts — it self-recreates otherwise.
|
|
176
|
+
|
|
177
|
+
**Works with threads?**
|
|
178
|
+
Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
|
|
179
|
+
|
|
180
|
+
**Works with Celery / background workers?**
|
|
181
|
+
Yes, `init()` in the worker process the same way as in a web process. For
|
|
182
|
+
tasks you catch yourself, use `report()` or `@devalerts.capture()`.
|
|
183
|
+
|
|
184
|
+
**Works on Windows / Linux / macOS?**
|
|
185
|
+
Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
|
|
186
|
+
paths.
|
|
187
|
+
|
|
188
|
+
## Privacy & Security
|
|
189
|
+
|
|
190
|
+
- The only network call devalerts makes is to `api.telegram.org` — no
|
|
191
|
+
telemetry, no analytics, nothing else phones home.
|
|
192
|
+
- No third-party server and no devalerts-run backend — messages go straight
|
|
193
|
+
from your process to your own Telegram bot.
|
|
194
|
+
- No accounts, no signup, no API key beyond the bot token you create and
|
|
195
|
+
control yourself.
|
|
196
|
+
- Basic secret redaction only (a few common token/key patterns) — do not
|
|
197
|
+
rely on this for sensitive production data; scrub what you can before it
|
|
198
|
+
ever reaches an exception message.
|
|
199
|
+
|
|
168
200
|
## What this does NOT do (by design)
|
|
169
201
|
|
|
170
202
|
- Grouping/rate limiting is local and in-process only (SQLite file, no
|
|
171
203
|
server) — the dashboard is a CLI table, not a web UI.
|
|
172
204
|
- No backend, no accounts — each user runs their own bot.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
205
|
+
|
|
206
|
+
## Roadmap
|
|
207
|
+
|
|
208
|
+
- Web dashboard (hosted, optional — the local CLI dashboard stays either way)
|
|
209
|
+
- Slack delivery
|
|
210
|
+
- Discord delivery
|
|
211
|
+
- Email delivery
|
|
212
|
+
|
|
213
|
+
## Development
|
|
214
|
+
|
|
215
|
+
uv sync --group dev
|
|
216
|
+
uv run pre-commit install
|
|
217
|
+
|
|
218
|
+
`pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
|
|
219
|
+
Run the full check manually with:
|
|
220
|
+
|
|
221
|
+
uv run pytest
|
|
222
|
+
uv run pre-commit run --all-files
|
|
176
223
|
|
|
177
224
|
## License
|
|
178
225
|
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# devalerts
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/devalerts/)
|
|
4
|
+
[](https://pypi.org/project/devalerts/)
|
|
4
5
|
[](https://pypi.org/project/devalerts/)
|
|
5
6
|
[](LICENSE)
|
|
7
|
+
[](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
|
|
8
|
+
[](https://github.com/astral-sh/ruff)
|
|
9
|
+
[](https://mypy-lang.org/)
|
|
6
10
|
|
|
7
11
|
[Русская версия](README.ru.md)
|
|
8
12
|
|
|
@@ -76,14 +80,7 @@ See what's grouped and what's currently rate-limited:
|
|
|
76
80
|
uv run devalerts dashboard
|
|
77
81
|
```
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
ID TYPE LOCATION LAST SEEN TOTAL STATUS
|
|
81
|
-
────────────────────────────────────────────────────────────────────────────────────
|
|
82
|
-
a1b2c3d4 ValueError app/orders.py:42 2m ago 14 ● limited
|
|
83
|
-
9f8e7d6c KeyError app/handlers/webhook.py:88 just now 1 ● sending
|
|
84
|
-
|
|
85
|
-
2 error groups, 1 currently rate-limited.
|
|
86
|
-
```
|
|
83
|
+

|
|
87
84
|
|
|
88
85
|
## Manually reporting a caught exception
|
|
89
86
|
|
|
@@ -124,11 +121,12 @@ Only exceptions that actually escape as server errors get reported — routing
|
|
|
124
121
|
404s and raised `HTTPException`s are already turned into responses by the
|
|
125
122
|
framework before the middleware sees them.
|
|
126
123
|
|
|
127
|
-
##
|
|
124
|
+
## Why not Sentry?
|
|
128
125
|
|
|
129
|
-
If you already run Sentry/Rollbar/etc., keep using it —
|
|
130
|
-
replacement.
|
|
131
|
-
|
|
126
|
+
If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
|
|
127
|
+
replacement. It's for the side project, internal tool, or small service that
|
|
128
|
+
doesn't have (and doesn't want) that infrastructure: no account to create, no
|
|
129
|
+
SDK to configure, no server to trust — just a bot token you already control.
|
|
132
130
|
|
|
133
131
|
| | devalerts | Sentry-style tracker |
|
|
134
132
|
|--------------------------|---------------------|-----------------------|
|
|
@@ -138,14 +136,63 @@ service that doesn't have (and doesn't want) that infrastructure yet:
|
|
|
138
136
|
| Grouping / rate limiting | yes, local SQLite | yes, server-side |
|
|
139
137
|
| Search, trends, releases | no | yes |
|
|
140
138
|
|
|
139
|
+
## FAQ
|
|
140
|
+
|
|
141
|
+
**Works with FastAPI / Starlette?**
|
|
142
|
+
Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
|
|
143
|
+
default excepthook never sees request errors.
|
|
144
|
+
|
|
145
|
+
**Works with Docker?**
|
|
146
|
+
Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
|
|
147
|
+
state file) is either writable inside the container or a mounted volume if
|
|
148
|
+
you want dedup state to survive restarts — it self-recreates otherwise.
|
|
149
|
+
|
|
150
|
+
**Works with threads?**
|
|
151
|
+
Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
|
|
152
|
+
|
|
153
|
+
**Works with Celery / background workers?**
|
|
154
|
+
Yes, `init()` in the worker process the same way as in a web process. For
|
|
155
|
+
tasks you catch yourself, use `report()` or `@devalerts.capture()`.
|
|
156
|
+
|
|
157
|
+
**Works on Windows / Linux / macOS?**
|
|
158
|
+
Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
|
|
159
|
+
paths.
|
|
160
|
+
|
|
161
|
+
## Privacy & Security
|
|
162
|
+
|
|
163
|
+
- The only network call devalerts makes is to `api.telegram.org` — no
|
|
164
|
+
telemetry, no analytics, nothing else phones home.
|
|
165
|
+
- No third-party server and no devalerts-run backend — messages go straight
|
|
166
|
+
from your process to your own Telegram bot.
|
|
167
|
+
- No accounts, no signup, no API key beyond the bot token you create and
|
|
168
|
+
control yourself.
|
|
169
|
+
- Basic secret redaction only (a few common token/key patterns) — do not
|
|
170
|
+
rely on this for sensitive production data; scrub what you can before it
|
|
171
|
+
ever reaches an exception message.
|
|
172
|
+
|
|
141
173
|
## What this does NOT do (by design)
|
|
142
174
|
|
|
143
175
|
- Grouping/rate limiting is local and in-process only (SQLite file, no
|
|
144
176
|
server) — the dashboard is a CLI table, not a web UI.
|
|
145
177
|
- No backend, no accounts — each user runs their own bot.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
178
|
+
|
|
179
|
+
## Roadmap
|
|
180
|
+
|
|
181
|
+
- Web dashboard (hosted, optional — the local CLI dashboard stays either way)
|
|
182
|
+
- Slack delivery
|
|
183
|
+
- Discord delivery
|
|
184
|
+
- Email delivery
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
uv sync --group dev
|
|
189
|
+
uv run pre-commit install
|
|
190
|
+
|
|
191
|
+
`pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
|
|
192
|
+
Run the full check manually with:
|
|
193
|
+
|
|
194
|
+
uv run pytest
|
|
195
|
+
uv run pre-commit run --all-files
|
|
149
196
|
|
|
150
197
|
## License
|
|
151
198
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devalerts"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.5"
|
|
4
4
|
description = "Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -43,8 +43,19 @@ Repository = "https://github.com/sslinNn/devalerts"
|
|
|
43
43
|
devalerts = "devalerts.cli:main"
|
|
44
44
|
|
|
45
45
|
[build-system]
|
|
46
|
-
requires = ["uv_build>=0.10.2,<0.
|
|
46
|
+
requires = ["uv_build>=0.10.2,<0.12.0"]
|
|
47
47
|
build-backend = "uv_build"
|
|
48
48
|
|
|
49
49
|
[dependency-groups]
|
|
50
|
-
dev = [
|
|
50
|
+
dev = [
|
|
51
|
+
"mypy>=1.19.1",
|
|
52
|
+
"pre-commit>=4.3.0",
|
|
53
|
+
"pytest>=8.4.2",
|
|
54
|
+
"ruff>=0.15.22",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
target-version = "py39"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
extend-select = ["BLE"]
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import sys
|
|
7
7
|
import threading
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Callable, Literal, Optional, TypedDict
|
|
8
10
|
|
|
9
11
|
from ._alert import _format_alert, _redact
|
|
10
12
|
from ._store import _DEFAULT_RATE_LIMIT_SECONDS, _fingerprint, _should_send
|
|
@@ -12,19 +14,43 @@ from ._telegram import _send_telegram_message
|
|
|
12
14
|
|
|
13
15
|
__all__: list[str] = ["init", "report", "capture", "ASGIMiddleware"]
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
_ExceptHook = Callable[
|
|
18
|
+
[type[BaseException], BaseException, Optional[TracebackType]], object
|
|
19
|
+
]
|
|
20
|
+
_ThreadingExceptHook = Callable[[threading.ExceptHookArgs], object]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _State(TypedDict):
|
|
24
|
+
bot_token: str | None
|
|
25
|
+
chat_id: int | str | None
|
|
26
|
+
redact: bool
|
|
27
|
+
rate_limit_seconds: int
|
|
28
|
+
# Seeded with the real default hooks below (never None) -- _excepthook/
|
|
29
|
+
# _threading_excepthook can only ever fire after init() has replaced
|
|
30
|
+
# sys.excepthook/threading.excepthook, by which point these are always
|
|
31
|
+
# set, so keeping them non-Optional avoids an unreachable None check.
|
|
32
|
+
prev_excepthook: _ExceptHook
|
|
33
|
+
prev_threading_excepthook: _ThreadingExceptHook
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_state: _State = {
|
|
16
37
|
"bot_token": None,
|
|
17
38
|
"chat_id": None,
|
|
18
39
|
"redact": True,
|
|
19
40
|
"rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
|
|
20
|
-
"prev_excepthook":
|
|
21
|
-
"prev_threading_excepthook":
|
|
41
|
+
"prev_excepthook": sys.excepthook,
|
|
42
|
+
"prev_threading_excepthook": threading.excepthook,
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
|
|
25
46
|
def _send_exception(exc_type, exc_value, tb) -> None:
|
|
47
|
+
if _state["bot_token"] is None or _state["chat_id"] is None:
|
|
48
|
+
print("devalerts: init() was not called, dropping alert", file=sys.stderr)
|
|
49
|
+
return
|
|
26
50
|
fingerprint, location = _fingerprint(exc_type, tb)
|
|
27
|
-
send, skipped = _should_send(
|
|
51
|
+
send, skipped = _should_send(
|
|
52
|
+
fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"]
|
|
53
|
+
)
|
|
28
54
|
if not send:
|
|
29
55
|
return
|
|
30
56
|
message = _format_alert(exc_type, exc_value, tb, skipped=skipped)
|
|
@@ -38,7 +64,10 @@ def _excepthook(exc_type, exc_value, tb) -> None:
|
|
|
38
64
|
try:
|
|
39
65
|
_send_exception(exc_type, exc_value, tb)
|
|
40
66
|
except Exception as error: # noqa: BLE001 - crash handler must never raise
|
|
41
|
-
print(
|
|
67
|
+
print(
|
|
68
|
+
f"devalerts: internal error while sending alert: {error}",
|
|
69
|
+
file=sys.stderr,
|
|
70
|
+
)
|
|
42
71
|
_state["prev_excepthook"](exc_type, exc_value, tb)
|
|
43
72
|
|
|
44
73
|
|
|
@@ -46,13 +75,15 @@ def _threading_excepthook(args) -> None:
|
|
|
46
75
|
try:
|
|
47
76
|
_send_exception(args.exc_type, args.exc_value, args.exc_traceback)
|
|
48
77
|
except Exception as error: # noqa: BLE001
|
|
49
|
-
print(
|
|
78
|
+
print(
|
|
79
|
+
f"devalerts: internal error while sending alert: {error}", file=sys.stderr
|
|
80
|
+
)
|
|
50
81
|
_state["prev_threading_excepthook"](args)
|
|
51
82
|
|
|
52
83
|
|
|
53
84
|
def init(
|
|
54
85
|
bot_token: str,
|
|
55
|
-
chat_id,
|
|
86
|
+
chat_id: int | str,
|
|
56
87
|
*,
|
|
57
88
|
redact: bool = True,
|
|
58
89
|
rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
|
|
@@ -78,7 +109,9 @@ def report(exc: BaseException | None = None) -> None:
|
|
|
78
109
|
if exc is None:
|
|
79
110
|
exc_type, exc_value, tb = sys.exc_info()
|
|
80
111
|
if exc_type is None:
|
|
81
|
-
raise RuntimeError(
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
"report() requires an active exception or an exc argument"
|
|
114
|
+
)
|
|
82
115
|
else:
|
|
83
116
|
exc_type, exc_value, tb = type(exc), exc, exc.__traceback__
|
|
84
117
|
_send_exception(exc_type, exc_value, tb)
|
|
@@ -92,7 +125,7 @@ class capture(contextlib.ContextDecorator):
|
|
|
92
125
|
def __enter__(self) -> "capture":
|
|
93
126
|
return self
|
|
94
127
|
|
|
95
|
-
def __exit__(self, exc_type, exc_value, tb) ->
|
|
128
|
+
def __exit__(self, exc_type, exc_value, tb) -> Literal[False]:
|
|
96
129
|
if exc_type is not None:
|
|
97
130
|
_send_exception(exc_type, exc_value, tb)
|
|
98
131
|
return False
|
|
@@ -9,7 +9,7 @@ _MAX_MESSAGE_LENGTH = 4096
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _format_alert(exc_type, exc_value, tb, skipped: int = 0) -> str:
|
|
12
|
-
header = f"\
|
|
12
|
+
header = f"\U0001f534 {exc_type.__name__}: {exc_value}"
|
|
13
13
|
if skipped:
|
|
14
14
|
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
15
15
|
body = "".join(traceback.format_exception(exc_type, exc_value, tb))
|
|
@@ -42,7 +42,9 @@ def _get_connection() -> sqlite3.Connection:
|
|
|
42
42
|
return conn
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
def _should_send(
|
|
45
|
+
def _should_send(
|
|
46
|
+
fingerprint: str, exc_type_name: str, location: str, rate_limit_seconds: int
|
|
47
|
+
) -> tuple[bool, int]:
|
|
46
48
|
now = time.time()
|
|
47
49
|
try:
|
|
48
50
|
conn = _get_connection()
|
|
@@ -79,12 +81,18 @@ def _should_send(fingerprint: str, exc_type_name: str, location: str, rate_limit
|
|
|
79
81
|
""",
|
|
80
82
|
(now, fingerprint),
|
|
81
83
|
)
|
|
82
|
-
conn.execute(
|
|
84
|
+
conn.execute(
|
|
85
|
+
"DELETE FROM error_groups WHERE last_seen < ?",
|
|
86
|
+
(now - _RETENTION_SECONDS,),
|
|
87
|
+
)
|
|
83
88
|
return send, skipped
|
|
84
89
|
finally:
|
|
85
90
|
conn.close()
|
|
86
91
|
except (sqlite3.Error, OSError) as error:
|
|
87
92
|
# ponytail: dedup/rate-limit state must never block an alert -- fail
|
|
88
93
|
# open (send, as if this were the first occurrence) on any DB error.
|
|
89
|
-
print(
|
|
94
|
+
print(
|
|
95
|
+
f"devalerts: dedup/rate-limit state error, sending anyway: {error}",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
90
98
|
return True, 0
|
|
@@ -10,7 +10,7 @@ import urllib.request
|
|
|
10
10
|
_TIMEOUT_SECONDS = 5
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def _send_telegram_message(bot_token: str, chat_id, text: str) -> None:
|
|
13
|
+
def _send_telegram_message(bot_token: str, chat_id: int | str, text: str) -> None:
|
|
14
14
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
15
15
|
payload = json.dumps({"chat_id": chat_id, "text": text}).encode("utf-8")
|
|
16
16
|
request = urllib.request.Request(
|
|
@@ -118,7 +118,9 @@ def _dashboard() -> int:
|
|
|
118
118
|
# doesn't know the app's actual rate_limit_seconds -- uses the
|
|
119
119
|
# library default. Upgrade to persisting the configured value if
|
|
120
120
|
# apps commonly override it.
|
|
121
|
-
in_window =
|
|
121
|
+
in_window = (
|
|
122
|
+
last_sent is not None and now - last_sent < _DEFAULT_RATE_LIMIT_SECONDS
|
|
123
|
+
)
|
|
122
124
|
if in_window:
|
|
123
125
|
limited_count += 1
|
|
124
126
|
status = style.red(f"{dot_char} limited")
|
|
File without changes
|
|
File without changes
|