2020from plain .runtime import settings
2121from plain .utils import timezone
2222
23- from .exceptions import DeferError , DeferJob
23+ from .exceptions import DeferJob
2424from .otel import (
2525 operation_duration_histogram ,
2626 process_metric_attributes ,
@@ -326,25 +326,19 @@ def run(self) -> JobResult:
326326 "job_process_uuid" : self .uuid ,
327327 },
328328 )
329- return self .defer (job = job , defer_exception = e )
329+ result = self .defer (job = job , defer_exception = e )
330+ if result .retry_job_request_uuid is None :
331+ # Re-enqueue was blocked by should_enqueue() —
332+ # either the default uniqueness rule (a peer
333+ # exists) or a user override (rate limit, custom
334+ # rule). Same treatment as the initial-enqueue
335+ # path's `job.enqueue.skipped`: not an error,
336+ # just visibility on the consumer span.
337+ span .set_attribute ("plain.jobs.defer.skipped" , True )
338+ return result
330339
331340 return self .convert_to_result (status = JobResultStatuses .SUCCESSFUL )
332341
333- except DeferError as e :
334- # Defer failed (e.g., concurrency limit reached during re-enqueue)
335- # The transaction was rolled back, so the JobProcess still exists in DB.
336- # The pk was restored in defer() before raising, so we can proceed normally.
337- logger .warning (
338- "Defer failed" ,
339- extra = {"job_class" : self .job_class , "error" : str (e )},
340- )
341- error_type = record_span_error (span , e , metric_attributes )
342- return self .convert_to_result (
343- status = JobResultStatuses .ERRORED ,
344- error = str (e ),
345- error_type = error_type ,
346- )
347-
348342 except Exception as e :
349343 # Note: if a rescuer already wrote JobResult(LOST) for this
350344 # row (heartbeat went stale during a long job, then the job
@@ -367,12 +361,17 @@ def defer(self, *, job: Job, defer_exception: DeferJob) -> JobResult:
367361 """Defer this job by re-enqueueing it for later execution.
368362
369363 Atomically deletes the JobProcess, re-enqueues the job, and creates
370- a JobResult linking to the new request. This ensures the concurrency
371- slot is released before attempting to re-enqueue.
372-
373- Raises:
374- DeferError: If the job cannot be re-enqueued (e.g., due to concurrency limits).
375- The transaction will be rolled back and the JobProcess will remain.
364+ a JobResult. The concurrency slot is released before re-enqueue so
365+ the new request's own `should_enqueue()` check can pass.
366+
367+ If `should_enqueue()` blocks the re-enqueue, the framework honors
368+ that signal — same convention as `run_in_worker()` and `retry_job()`,
369+ which both return `None` silently in the same situation. The
370+ JobResult is still `DEFERRED` but `retry_job_request_uuid` is
371+ `None`, the error message records that the re-enqueue was skipped,
372+ and the caller stamps `plain.jobs.defer.skipped=True` on the
373+ consumer span so this case is queryable in APM without surfacing
374+ as an exception.
376375 """
377376 # Calculate new retry_attempt based on increment_retries
378377 retry_attempt = (
@@ -383,7 +382,6 @@ def defer(self, *, job: Job, defer_exception: DeferJob) -> JobResult:
383382
384383 with transaction .atomic ():
385384 # 1. Save JobProcess state and delete (releases concurrency slot)
386- saved_id = self .id
387385 job_process_uuid = self .uuid
388386 job_request_uuid = self .job_request_uuid
389387 requested_at = self .requested_at
@@ -400,21 +398,23 @@ def defer(self, *, job: Job, defer_exception: DeferJob) -> JobResult:
400398 concurrency_key = self .concurrency_key ,
401399 )
402400
403- # Check if re-enqueue failed
404401 if new_job_request is None :
405- # Restore id since transaction will roll back and object still exists
406- self .id = saved_id
407- raise DeferError (
408- f"Failed to re-enqueue deferred job { self .job_class } : "
409- f"concurrency limit reached for key '{ self .concurrency_key } '"
402+ error = (
403+ f"Deferred for { defer_exception .delay } seconds "
404+ f"(re-enqueue skipped: should_enqueue() returned False "
405+ f"for concurrency_key '{ self .concurrency_key } ')"
410406 )
407+ retry_job_request_uuid = None
408+ else :
409+ error = f"Deferred for { defer_exception .delay } seconds"
410+ retry_job_request_uuid = new_job_request .uuid
411411
412- # 3. Create JobResult linking to new request
412+ # 3. Create JobResult ( linking to new request if one was created)
413413 result = JobResult .query .create (
414414 ended_at = timezone .now (),
415- error = f"Deferred for { defer_exception . delay } seconds" ,
415+ error = error ,
416416 status = JobResultStatuses .DEFERRED ,
417- retry_job_request_uuid = new_job_request . uuid ,
417+ retry_job_request_uuid = retry_job_request_uuid ,
418418 # From the JobProcess
419419 job_process_uuid = job_process_uuid ,
420420 started_at = started_at ,
0 commit comments