22486 - Add Configurable timeout for webhooks
This commit is contained in:
parent
ab07002df8
commit
30c61a3aa4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class WebhookTestCase(APIViewTestCases.APIViewTestCase):
|
|||
{
|
||||
'name': 'Webhook 4',
|
||||
'payload_url': 'http://example.com/?4',
|
||||
'timeout': 15,
|
||||
},
|
||||
{
|
||||
'name': 'Webhook 5',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
|
|
|||
Loading…
Reference in New Issue