Skip to content

Commit 237ad09

Browse files
feat: allow ignoring task exceptions in Sentry
1 parent 42c2944 commit 237ad09

2 files changed

Lines changed: 167 additions & 22 deletions

File tree

modelq/app/base.py

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def __init__(
131131
sentry_release: Optional[str] = None,
132132
sentry_send_default_pii: bool = False,
133133
sentry_debug: bool = False,
134+
sentry_ignore_exceptions: Optional[Any] = None,
134135
silent: bool = False,
135136
**kwargs,
136137
):
@@ -164,6 +165,9 @@ def __init__(
164165
self.task_ttl = task_ttl or self.TASK_TTL
165166
self.inactive_if_worker_boot_fail = inactive_if_worker_boot_fail
166167
self.worker_healthy = True # Track worker health status
168+
self.sentry_ignore_exceptions = self._normalize_sentry_ignore_exceptions(
169+
sentry_ignore_exceptions
170+
)
167171

168172
# Silent mode: suppress all modelq logging output
169173
if silent:
@@ -182,13 +186,50 @@ def __init__(
182186
server_name=self.server_id,
183187
send_default_pii=sentry_send_default_pii,
184188
debug=sentry_debug,
189+
ignore_errors=self.sentry_ignore_exceptions or None,
185190
)
186191
if self.sentry_enabled:
187192
logger.info("Sentry integration enabled for ModelQ")
188193

189194
# Register this server in Redis (with an initial heartbeat)
190195
self.register_server()
191196

197+
@staticmethod
198+
def _normalize_sentry_ignore_exceptions(ignore_exceptions: Optional[Any]) -> tuple:
199+
"""
200+
Normalize Sentry ignored exception configuration to a tuple of exception types.
201+
202+
Accepts a single exception class or an iterable of exception classes.
203+
"""
204+
if ignore_exceptions is None:
205+
return ()
206+
207+
if isinstance(ignore_exceptions, type):
208+
ignore_exceptions = (ignore_exceptions,)
209+
else:
210+
ignore_exceptions = tuple(ignore_exceptions)
211+
212+
invalid = [
213+
exc_type
214+
for exc_type in ignore_exceptions
215+
if not isinstance(exc_type, type) or not issubclass(exc_type, BaseException)
216+
]
217+
if invalid:
218+
raise TypeError(
219+
"sentry_ignore_exceptions must contain exception classes, "
220+
f"got invalid values: {invalid}"
221+
)
222+
223+
return ignore_exceptions
224+
225+
def _should_ignore_sentry_exception(self, exc: Exception) -> bool:
226+
"""Return True when an exception should fail normally but not report to Sentry."""
227+
return (
228+
exc is not None
229+
and bool(self.sentry_ignore_exceptions)
230+
and isinstance(exc, self.sentry_ignore_exceptions)
231+
)
232+
192233
def _connect_to_redis(
193234
self,
194235
host: str,
@@ -723,9 +764,16 @@ def worker_loop(worker_id):
723764
)
724765

725766
except TaskProcessingError as e:
726-
logger.error(
727-
f"Worker {worker_id} encountered a TaskProcessingError: {e}"
728-
)
767+
if self._should_ignore_sentry_exception(e.__cause__):
768+
logger.warning(
769+
"Worker %s encountered an ignored Sentry TaskProcessingError: %s",
770+
worker_id,
771+
e,
772+
)
773+
else:
774+
logger.error(
775+
f"Worker {worker_id} encountered a TaskProcessingError: {e}"
776+
)
729777
if task.payload.get("retries", 0) > 0:
730778
new_task_dict = task.to_dict()
731779
new_task_dict["payload"] = task.original_payload
@@ -926,27 +974,41 @@ def process_task(self, task: Task) -> None:
926974
# 2) Webhook (if configured)
927975
self.post_error_to_webhook(task, e)
928976

929-
# 3) Sentry (if enabled) - capture with full traceback
977+
# 3) Sentry (if enabled) - capture with full traceback unless ignored
930978
if self.sentry_enabled:
931-
import sys
932-
event_id = capture_task_exception(
933-
exc=e,
934-
task_id=task.task_id,
935-
task_name=task.task_name,
936-
payload=task.payload,
937-
worker_id=self.server_id,
938-
additional_context={
939-
"created_at": task.created_at,
940-
"started_at": task.started_at,
941-
"additional_params": task.additional_params,
942-
},
943-
exc_info=sys.exc_info(), # Pass full traceback with line numbers
944-
)
945-
if event_id:
946-
logger.info(f"Error reported to Sentry: {event_id}")
979+
if self._should_ignore_sentry_exception(e):
980+
logger.info(
981+
"Skipping Sentry capture for ignored exception %s in task %s",
982+
type(e).__name__,
983+
task.task_name,
984+
)
985+
else:
986+
import sys
987+
event_id = capture_task_exception(
988+
exc=e,
989+
task_id=task.task_id,
990+
task_name=task.task_name,
991+
payload=task.payload,
992+
worker_id=self.server_id,
993+
additional_context={
994+
"created_at": task.created_at,
995+
"started_at": task.started_at,
996+
"additional_params": task.additional_params,
997+
},
998+
exc_info=sys.exc_info(), # Pass full traceback with line numbers
999+
)
1000+
if event_id:
1001+
logger.info(f"Error reported to Sentry: {event_id}")
9471002

948-
logger.error(f"Task {task.task_name} failed with error: {e}")
949-
raise TaskProcessingError(task.task_name, str(e))
1003+
if self._should_ignore_sentry_exception(e):
1004+
logger.warning(
1005+
"Task %s failed with ignored Sentry exception: %s",
1006+
task.task_name,
1007+
e,
1008+
)
1009+
else:
1010+
logger.error(f"Task {task.task_name} failed with error: {e}")
1011+
raise TaskProcessingError(task.task_name, str(e)) from e
9501012

9511013
finally:
9521014
self.redis_client.srem("processing_tasks", task.task_id)

tests/test_base.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,89 @@ def simple_task():
935935
assert mq.worker_healthy is True
936936

937937

938+
def test_modelq_reports_unignored_task_exceptions_to_sentry(mock_redis, monkeypatch):
939+
"""Task errors are reported to Sentry by default when Sentry is enabled."""
940+
from modelq.app.tasks import Task
941+
from modelq.exceptions import TaskProcessingError
942+
943+
class ExpectedUserError(Exception):
944+
pass
945+
946+
captured = []
947+
948+
def fake_capture_task_exception(**kwargs):
949+
captured.append(kwargs)
950+
return "event-id"
951+
952+
monkeypatch.setattr(
953+
"modelq.app.base.capture_task_exception",
954+
fake_capture_task_exception,
955+
)
956+
957+
mq = ModelQ(redis_client=mock_redis)
958+
mq.sentry_enabled = True
959+
960+
@mq.task()
961+
def failing_task():
962+
raise ExpectedUserError("face not detected")
963+
964+
task = Task(
965+
task_name="failing_task",
966+
payload={"data": {"args": (), "kwargs": {}}, "timeout": None, "stream": False, "retries": 0},
967+
task_id="reported-error",
968+
)
969+
970+
with pytest.raises(TaskProcessingError):
971+
mq.process_task(task)
972+
973+
assert len(captured) == 1
974+
assert isinstance(captured[0]["exc"], ExpectedUserError)
975+
976+
977+
def test_modelq_raises_but_does_not_report_ignored_task_exceptions_to_sentry(mock_redis, monkeypatch):
978+
"""Ignored task exceptions still fail the task but are not captured by Sentry."""
979+
from modelq.app.tasks import Task
980+
from modelq.exceptions import TaskProcessingError
981+
982+
class ExpectedUserError(Exception):
983+
pass
984+
985+
captured = []
986+
987+
def fake_capture_task_exception(**kwargs):
988+
captured.append(kwargs)
989+
return "event-id"
990+
991+
monkeypatch.setattr(
992+
"modelq.app.base.capture_task_exception",
993+
fake_capture_task_exception,
994+
)
995+
996+
mq = ModelQ(
997+
redis_client=mock_redis,
998+
sentry_ignore_exceptions=[ExpectedUserError],
999+
)
1000+
mq.sentry_enabled = True
1001+
1002+
@mq.task()
1003+
def ignored_failing_task():
1004+
raise ExpectedUserError("face not detected")
1005+
1006+
task = Task(
1007+
task_name="ignored_failing_task",
1008+
payload={"data": {"args": (), "kwargs": {}}, "timeout": None, "stream": False, "retries": 0},
1009+
task_id="ignored-error",
1010+
)
1011+
1012+
with pytest.raises(TaskProcessingError):
1013+
mq.process_task(task)
1014+
1015+
assert captured == []
1016+
stored = _json_bytes_to_dict(mock_redis.get("task:ignored-error"))
1017+
assert stored["status"] == "failed"
1018+
assert stored["error"]["type"] == "ExpectedUserError"
1019+
1020+
9381021
def test_worker_health_task_pickup_blocked(mock_redis):
9391022
"""Test that unhealthy workers don't pick up tasks."""
9401023
from modelq.app.middleware import Middleware

0 commit comments

Comments
 (0)