22486 - Add Configurable timeout for webhooks

This commit is contained in:
Arthur 2026-07-21 16:42:50 -07:00
parent ab07002df8
commit 30c61a3aa4
19 changed files with 125 additions and 11 deletions

View File

@ -306,3 +306,11 @@ The base unit for disk sizes. Set this to `1024` to use binary prefixes (MiB, Gi
Default: `1000`
The base unit for RAM sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).
---
## WEBHOOK_DEFAULT_TIMEOUT
Default: `30`
The default maximum time (in seconds) to wait for a response when sending a webhook. This value is used for any webhook which does not define its own timeout. Setting a value lower than [`RQ_DEFAULT_TIMEOUT`](#rq_default_timeout) prevents an unresponsive receiver from holding a background worker open for the full duration of the job.

View File

@ -75,6 +75,10 @@ Controls whether validation of the receiver's SSL certificate is enforced when H
The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (if not using the system defaults).
### Timeout
The maximum time (in seconds) to wait for a response from the receiver before the request is considered failed. If left blank, the global [`WEBHOOK_DEFAULT_TIMEOUT`](../../configuration/miscellaneous.md#webhook_default_timeout) configuration value is used.
## Context Data
The following context variables are available to the text and link templates.

View File

@ -48,6 +48,6 @@ class WebhookSerializer(OwnerMixin, NetBoxModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'payload_url', 'http_method',
'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path',
'custom_fields', 'owner', 'tags', 'created', 'last_updated',
'timeout', 'custom_fields', 'owner', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -82,7 +82,7 @@ class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
model = Webhook
fields = (
'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification',
'ca_file_path', 'description',
'ca_file_path', 'timeout', 'description',
)
def search(self, queryset, name, value):

View File

@ -279,8 +279,14 @@ class WebhookBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
required=False,
label=_('CA file path')
)
timeout = forms.IntegerField(
required=False,
min_value=1,
max_value=3600,
label=_('Timeout')
)
nullable_fields = ('secret', 'ca_file_path')
nullable_fields = ('secret', 'ca_file_path', 'timeout')
class EventRuleBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):

View File

@ -254,7 +254,7 @@ class WebhookImportForm(OwnerCSVMixin, NetBoxModelImportForm):
model = Webhook
fields = (
'name', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template',
'secret', 'ssl_verification', 'ca_file_path', 'description', 'owner', 'tags'
'secret', 'ssl_verification', 'ca_file_path', 'timeout', 'description', 'owner', 'tags'
)

View File

@ -587,6 +587,7 @@ class WebhookForm(OwnerMixin, NetBoxModelForm):
name=_('HTTP Request')
),
FieldSet('ssl_verification', 'ca_file_path', name=_('SSL')),
FieldSet('timeout', name=_('Timeout')),
)
class Meta:

View File

@ -396,6 +396,7 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF
secret: StrFilterLookup | None = strawberry_django.filter_field()
ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field()
ca_file_path: StrFilterLookup | None = strawberry_django.filter_field()
timeout: FilterLookup[int] | None = strawberry_django.filter_field()
events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)

View File

@ -0,0 +1,26 @@
# Generated by Django 6.0.7 on 2026-07-21 23:28
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("extras", "0141_custom_field_nulls_first"),
]
operations = [
migrations.AddField(
model_name="webhook",
name="timeout",
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(3600),
],
),
),
]

View File

@ -5,7 +5,7 @@ from pathlib import Path
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.validators import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
@ -247,6 +247,19 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch
"The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults."
)
)
timeout = models.PositiveSmallIntegerField(
verbose_name=_('timeout'),
null=True,
blank=True,
validators=(
MinValueValidator(1),
MaxValueValidator(3600),
),
help_text=_(
"The maximum time (in seconds) to wait for a response before failing the request. Leave blank to use "
"the system default (WEBHOOK_DEFAULT_TIMEOUT)."
)
)
events = GenericRelation(
EventRule,
content_type_field='action_object_type',

View File

@ -503,7 +503,7 @@ class WebhookTable(NetBoxTable):
model = Webhook
fields = (
'pk', 'id', 'name', 'http_method', 'payload_url', 'http_content_type', 'secret', 'ssl_verification',
'ca_file_path', 'description', 'tags', 'created', 'last_updated',
'ca_file_path', 'timeout', 'description', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'http_method', 'payload_url', 'description',

View File

@ -43,6 +43,7 @@ class WebhookTestCase(APIViewTestCases.APIViewTestCase):
{
'name': 'Webhook 4',
'payload_url': 'http://example.com/?4',
'timeout': 15,
},
{
'name': 'Webhook 5',

View File

@ -427,6 +427,9 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
self.assertEqual(request.headers['X-Hook-Signature'], signature)
self.assertEqual(request.headers['X-Foo'], 'Bar')
# The webhook does not define its own timeout, so the global default should be used
self.assertEqual(kwargs['timeout'], settings.WEBHOOK_DEFAULT_TIMEOUT)
# Validate the outgoing request body
body = json.loads(request.body)
self.assertEqual(body['event'], 'created')
@ -465,6 +468,37 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
with patch.object(Session, 'send', dummy_send):
send_webhook(**job.kwargs)
def test_send_webhook_per_webhook_timeout(self):
"""
A webhook which defines its own timeout should use that value in preference to the
global WEBHOOK_DEFAULT_TIMEOUT.
"""
webhook = Webhook.objects.get(name='Webhook 1')
webhook.timeout = 5
webhook.save()
def dummy_send(_, request, **kwargs):
self.assertEqual(kwargs['timeout'], 5)
return HttpResponse()
request = RequestFactory().get(reverse('dcim:site_add'))
request.id = uuid.uuid4()
request.user = self.user
webhooks_queue = {}
site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_event(
webhooks_queue,
instance=site,
request=request,
event_type=OBJECT_CREATED,
)
flush_events(list(webhooks_queue.values()))
job = self.queue.jobs[0]
with patch.object(Session, 'send', dummy_send):
send_webhook(**job.kwargs)
def test_job_completed_webhook_without_request(self):
"""
Ensure job_end event processing can enqueue a webhook even when the EventContext

View File

@ -227,6 +227,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
payload_url='http://example.com/?1',
http_method='GET',
ssl_verification=True,
timeout=10,
description='foobar1'
),
Webhook(
@ -234,6 +235,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
payload_url='http://example.com/?2',
http_method='POST',
ssl_verification=True,
timeout=20,
description='foobar2'
),
Webhook(
@ -241,6 +243,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
payload_url='http://example.com/?3',
http_method='PATCH',
ssl_verification=False,
timeout=30,
description='foobar3'
),
Webhook(
@ -278,6 +281,10 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
params = {'ssl_verification': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_timeout(self):
params = {'timeout': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class EventRuleTestCase(TestCase, BaseFilterSetTests):
queryset = EventRule.objects.all()

View File

@ -626,14 +626,15 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'payload_url': 'http://example.com/?x',
'http_method': 'GET',
'http_content_type': 'application/foo',
'timeout': 45,
'description': 'My webhook',
}
cls.csv_data = (
"name,payload_url,http_method,http_content_type,description",
"Webhook 4,http://example.com/?4,GET,application/json,Foo",
"Webhook 5,http://example.com/?5,GET,application/json,Bar",
"Webhook 6,http://example.com/?6,GET,application/json,Baz",
"name,payload_url,http_method,http_content_type,timeout,description",
"Webhook 4,http://example.com/?4,GET,application/json,15,Foo",
"Webhook 5,http://example.com/?5,GET,application/json,,Bar",
"Webhook 6,http://example.com/?6,GET,application/json,,Baz",
)
cls.csv_update_data = (
@ -645,6 +646,7 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'http_method': 'GET',
'timeout': 60,
}

View File

@ -340,6 +340,7 @@ class WebhookHTTPPanel(panels.ObjectAttributesPanel):
payload_url = attrs.TextAttr('payload_url', label=_('Payload URL'), style='font-monospace')
http_content_type = attrs.TextAttr('http_content_type', label=_('HTTP content type'))
secret = attrs.TextAttr('secret')
timeout = attrs.TextAttr('timeout')
class WebhookSSLPanel(panels.ObjectAttributesPanel):

View File

@ -3,6 +3,7 @@ import hmac
import logging
import requests
from django.conf import settings
from django_rq import job
from jinja2.exceptions import TemplateError
@ -112,13 +113,16 @@ def send_webhook(event_rule, object_type, event_type, data, timestamp, request=N
if webhook.secret != '':
prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
# Determine the request timeout, preferring the webhook-specific value over the global default
timeout = webhook.timeout if webhook.timeout is not None else settings.WEBHOOK_DEFAULT_TIMEOUT
# Send the request
with requests.Session() as session:
session.verify = webhook.ssl_verification
if webhook.ca_file_path:
session.verify = webhook.ca_file_path
proxies = resolve_proxies(url=url, context={'client': webhook})
response = session.send(prepared_request, proxies=proxies)
response = session.send(prepared_request, proxies=proxies, timeout=timeout)
if 200 <= response.status_code <= 299:
logger.info(f"Request succeeded; response status {response.status_code}")

View File

@ -223,6 +223,11 @@ RQ_DEFAULT_TIMEOUT = 300
# this setting is derived from the installed location.
# SCRIPTS_ROOT = '/path/to/netbox/scripts'
# The default maximum time (in seconds) to wait for a response when sending a webhook, unless overridden on the
# individual webhook. This prevents an unresponsive receiver from holding a background worker open for the full
# RQ_DEFAULT_TIMEOUT duration.
WEBHOOK_DEFAULT_TIMEOUT = 30
# The name to use for the session cookie.
SESSION_COOKIE_NAME = 'sessionid'

View File

@ -215,6 +215,7 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', None)
STORAGES = getattr(configuration, 'STORAGES', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
WEBHOOK_DEFAULT_TIMEOUT = getattr(configuration, 'WEBHOOK_DEFAULT_TIMEOUT', 30)
DISK_BASE_UNIT = getattr(configuration, 'DISK_BASE_UNIT', 1000)
if DISK_BASE_UNIT not in [1000, 1024]:
raise ImproperlyConfigured(f"DISK_BASE_UNIT must be 1000 or 1024 (found {DISK_BASE_UNIT})")