Merge lp:~wgrant/launchpad/webhook-retries-manual into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 17651
Proposed branch: lp:~wgrant/launchpad/webhook-retries-manual
Merge into: lp:launchpad
Prerequisite: lp:~wgrant/launchpad/webhook-retries
Diff against target: 216 lines (+98/-18)
5 files modified
lib/lp/services/job/model/job.py (+2/-1)
lib/lp/services/webhooks/interfaces.py (+11/-0)
lib/lp/services/webhooks/model.py (+9/-0)
lib/lp/services/webhooks/tests/test_webhookjob.py (+62/-17)
lib/lp/services/webhooks/tests/test_webservice.py (+14/-0)
To merge this branch: bzr merge lp:~wgrant/launchpad/webhook-retries-manual
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+266195@code.launchpad.net

Commit message

Add and export WebhookDeliveryJob.retry() to immediate force another attempt.

Description of the change

Add and export WebhookDeliveryJob.retry() to immediate force another attempt.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/services/job/model/job.py'
2--- lib/lp/services/job/model/job.py 2015-07-29 09:06:46 +0000
3+++ lib/lp/services/job/model/job.py 2015-07-29 09:06:47 +0000
4@@ -203,7 +203,8 @@
5 transaction.abort()
6 # Commit the transaction to update the DB time.
7 transaction.commit()
8- self._set_status(JobStatus.WAITING)
9+ if self.status != JobStatus.WAITING:
10+ self._set_status(JobStatus.WAITING)
11 self.date_finished = datetime.datetime.now(UTC)
12 if add_commit_hook is not None:
13 add_commit_hook()
14
15=== modified file 'lib/lp/services/webhooks/interfaces.py'
16--- lib/lp/services/webhooks/interfaces.py 2015-07-29 09:06:46 +0000
17+++ lib/lp/services/webhooks/interfaces.py 2015-07-29 09:06:47 +0000
18@@ -28,6 +28,7 @@
19 export_as_webservice_entry,
20 export_destructor_operation,
21 export_factory_operation,
22+ export_write_operation,
23 exported,
24 operation_for_version,
25 REQUEST_USER,
26@@ -226,6 +227,16 @@
27 title=_('Event payload'),
28 key_type=TextLine(), required=True, readonly=True))
29
30+ @export_write_operation()
31+ @operation_for_version("devel")
32+ def retry():
33+ """Attempt to deliver the event again.
34+
35+ Launchpad will automatically retry regularly for 24 hours, but
36+ this can be used after it gives up or to avoid waiting for the
37+ next automatic attempt.
38+ """
39+
40
41 class IWebhookDeliveryJobSource(IJobSource):
42
43
44=== modified file 'lib/lp/services/webhooks/model.py'
45--- lib/lp/services/webhooks/model.py 2015-07-29 09:06:46 +0000
46+++ lib/lp/services/webhooks/model.py 2015-07-29 09:06:47 +0000
47@@ -294,6 +294,7 @@
48 class_job_type = WebhookJobType.DELIVERY
49
50 retry_error_types = (WebhookDeliveryRetry,)
51+ user_error_types = (WebhookDeliveryFailure,)
52
53 # Effectively infinite, as we give up by checking
54 # retry_automatically and raising a fatal exception instead.
55@@ -351,6 +352,14 @@
56 def _time_since_first_attempt(self):
57 return datetime.now(utc) - (self.date_first_sent or self.date_created)
58
59+ def retry(self):
60+ """See `IWebhookDeliveryJob`."""
61+ # Unset any retry delay and reset attempt_count to prevent
62+ # queue() from delaying it again.
63+ self.scheduled_start = None
64+ self.attempt_count = 0
65+ self.queue()
66+
67 @property
68 def retry_automatically(self):
69 return self._time_since_first_attempt < timedelta(days=1)
70
71=== modified file 'lib/lp/services/webhooks/tests/test_webhookjob.py'
72--- lib/lp/services/webhooks/tests/test_webhookjob.py 2015-07-29 09:06:46 +0000
73+++ lib/lp/services/webhooks/tests/test_webhookjob.py 2015-07-29 09:06:47 +0000
74@@ -21,6 +21,7 @@
75 GreaterThan,
76 Is,
77 KeysEqual,
78+ LessThan,
79 MatchesAll,
80 MatchesStructure,
81 Not,
82@@ -337,28 +338,27 @@
83 job.date_first_sent - timedelta(hours=24)).isoformat()
84 self.assertFalse(job.retry_automatically)
85
86+ def runJob(self, job):
87+ with dbuser("webhookrunner"):
88+ runner = JobRunner([job])
89+ runner.runAll()
90+ job.lease_expires = None
91+ if len(runner.completed_jobs) == 1 and not runner.incomplete_jobs:
92+ return True
93+ if len(runner.incomplete_jobs) == 1 and not runner.completed_jobs:
94+ return False
95+ if not runner.incomplete_jobs and not runner.completed_jobs:
96+ return None
97+ raise Exception("Unexpected jobs.")
98+
99 def test_automatic_retries(self):
100 hook = self.factory.makeWebhook()
101 job = WebhookDeliveryJob.create(hook, payload={'foo': 'bar'})
102-
103 client = MockWebhookClient(response_status=404)
104 self.useFixture(ZopeUtilityFixture(client, IWebhookClient))
105
106- def run_job(job):
107- with dbuser("webhookrunner"):
108- runner = JobRunner([job])
109- runner.runAll()
110- if len(runner.completed_jobs) == 1 and not runner.incomplete_jobs:
111- return True
112- if len(runner.incomplete_jobs) == 1 and not runner.completed_jobs:
113- job.lease_expires = None
114- return False
115- if not runner.incomplete_jobs and not runner.completed_jobs:
116- return None
117- raise Exception("Unexpected jobs.")
118-
119 # The first attempt fails but schedules a retry five minutes later.
120- self.assertEqual(False, run_job(job))
121+ self.assertEqual(False, self.runJob(job))
122 self.assertEqual(JobStatus.WAITING, job.status)
123 self.assertEqual(False, job.successful)
124 self.assertTrue(job.pending)
125@@ -370,7 +370,7 @@
126 job.json_data['date_first_sent'] = (
127 job.date_first_sent - timedelta(minutes=5)).isoformat()
128 job.scheduled_start -= timedelta(minutes=5)
129- self.assertEqual(False, run_job(job))
130+ self.assertEqual(False, self.runJob(job))
131 self.assertEqual(JobStatus.WAITING, job.status)
132 self.assertEqual(False, job.successful)
133 self.assertTrue(job.pending)
134@@ -380,11 +380,56 @@
135 job.json_data['date_first_sent'] = (
136 job.date_first_sent - timedelta(hours=24)).isoformat()
137 job.scheduled_start -= timedelta(hours=24)
138- self.assertEqual(False, run_job(job))
139+ self.assertEqual(False, self.runJob(job))
140 self.assertEqual(JobStatus.FAILED, job.status)
141 self.assertEqual(False, job.successful)
142 self.assertFalse(job.pending)
143
144+ def test_manual_retries(self):
145+ hook = self.factory.makeWebhook()
146+ job = WebhookDeliveryJob.create(hook, payload={'foo': 'bar'})
147+ client = MockWebhookClient(response_status=404)
148+ self.useFixture(ZopeUtilityFixture(client, IWebhookClient))
149+
150+ # Simulate a first attempt failure.
151+ self.assertEqual(False, self.runJob(job))
152+ self.assertEqual(JobStatus.WAITING, job.status)
153+ self.assertIsNot(None, job.scheduled_start)
154+
155+ # A manual retry brings the scheduled start forward.
156+ job.retry()
157+ self.assertEqual(JobStatus.WAITING, job.status)
158+ self.assertIs(None, job.scheduled_start)
159+
160+ # Force the next attempt to fail hard by pretending it was more
161+ # than 24 hours later.
162+ job.json_data['date_first_sent'] = (
163+ job.date_first_sent - timedelta(hours=24)).isoformat()
164+ self.assertEqual(False, self.runJob(job))
165+ self.assertEqual(JobStatus.FAILED, job.status)
166+
167+ # A manual retry brings the job out of FAILED and schedules it
168+ # to run as soon as possible. If it fails again, it fails hard;
169+ # the initial attempt more than 24 hours ago is remembered.
170+ job.retry()
171+ self.assertEqual(JobStatus.WAITING, job.status)
172+ self.assertIs(None, job.scheduled_start)
173+ self.assertEqual(False, self.runJob(job))
174+ self.assertEqual(JobStatus.FAILED, job.status)
175+
176+ # A completed job can be retried just like a failed one. The
177+ # endpoint may have erroneously returned a 200 without recording
178+ # the event.
179+ client.response_status = 200
180+ job.retry()
181+ self.assertEqual(JobStatus.WAITING, job.status)
182+ self.assertEqual(True, self.runJob(job))
183+ self.assertEqual(JobStatus.COMPLETED, job.status)
184+ job.retry()
185+ self.assertEqual(JobStatus.WAITING, job.status)
186+ self.assertEqual(True, self.runJob(job))
187+ self.assertEqual(JobStatus.COMPLETED, job.status)
188+
189
190 class TestViaCronscript(TestCaseWithFactory):
191
192
193=== modified file 'lib/lp/services/webhooks/tests/test_webservice.py'
194--- lib/lp/services/webhooks/tests/test_webservice.py 2015-07-29 09:06:46 +0000
195+++ lib/lp/services/webhooks/tests/test_webservice.py 2015-07-29 09:06:47 +0000
196@@ -170,6 +170,20 @@
197 'date_created': Not(Is(None)),
198 'date_sent': Is(None)})))
199
200+ def test_retry(self):
201+ with person_logged_in(self.owner):
202+ self.delivery.start()
203+ self.delivery.fail()
204+ representation = self.webservice.get(
205+ self.delivery_url, api_version='devel').jsonBody()
206+ self.assertFalse(representation['pending'])
207+ response = self.webservice.named_post(
208+ self.delivery_url, 'retry', api_version='devel')
209+ self.assertEqual(200, response.status)
210+ representation = self.webservice.get(
211+ self.delivery_url, api_version='devel').jsonBody()
212+ self.assertTrue(representation['pending'])
213+
214
215 class TestWebhookTarget(TestCaseWithFactory):
216 layer = DatabaseFunctionalLayer