Provider-Quota Hold

Park a scheduled job past a provider's rate-limit window instead of re-firing every tick

A scheduled job whose provider is rate-limited stops re-firing until the provider’s own reset window elapses — one notice, no wasted requests, one clean resume.

Diagram

⏰ Scheduled Tick

❌ 429 + Retry-After

⏸️ Park until reset + slack

🤫 Intervening ticks coalesce

🤖 First legal tick reaches model

✅ Hold cleared

Quick Start

  1. On by default

    Scheduling an agent already gets the hold — no config needed.

    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="daily-digest",
        instructions="Summarise overnight repo activity.",
    )
    
    # A 429 from the provider parks this job until the reset window elapses.
    # No extra config — the wrapper's default RunPolicy has hold_on_rate_limit=True.
    agent.start("Every day at 02:00, brief last-night activity to slack:#eng.")
  2. Tune the slack, or turn it off

    Construct RunPolicy explicitly and pass it to the executor.

    python
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore
    from praisonai_bot.scheduler import ScheduledAgentExecutor
    from praisonai.scheduler import RunPolicy
    
    agent = Agent(
        name="daily-digest",
        instructions="Summarise overnight repo activity.",
    )
    
    runner = ScheduleRunner(FileScheduleStore())
    
    executor = ScheduledAgentExecutor(
        runner=runner,
        agent_resolver=lambda _id: agent,
        run_policy=RunPolicy(
            hold_on_rate_limit=True,   # default — park past the reset window
            hold_slack_seconds=120.0,  # wait 2 min past the window before resuming
        ),
    )

    Set hold_on_rate_limit=False to keep the prior fire-every-tick behaviour.


How It Works

The first 429 parks the job past the provider’s reset window; intervening ticks skip silently; the first tick after the window reaches the model and clears the hold.

Diagram
DeliveryJob StoreProviderExecutorSchedulerDeliveryJob StoreProviderExecutorSchedulerintervening ticks: is_due False → no run, no deliverydue tickrun429 + Retry-Afterquota_hold_from_failure() → hold_untilmark_run persists hold_untilone “held until N” noticefirst tick after windowrun (reaches model)result_clear_quota_hold → hold_until None
StepWhat happens
First 429Executor reads Retry-After, sets job.hold_until = now + window + slack
mark_runPersists hold_until so the park survives a restart
NoticeOne “held until N” message via the de-duplicating incident path
Intervening ticksis_due returns False while now < hold_until — no run, no delivery
First legal tickRun reaches the model; _clear_quota_hold lifts the park on success
Fresh 429Re-parks past the new window (never shortens an existing later hold)

Keep, Tune, or Disable?

Recurring jobs keep the default; flaky-reset providers raise the slack; one-shot jobs or custom-wrapper handlers disable it.

Diagram

Yes, standard

Yes, provider rounds
its reset instant

One-shot / you handle
429s in a wrapper

Recurring job on a
shared provider quota?

Keep default
hold_on_rate_limit=True

Increase slack
hold_slack_seconds=120+

Disable
hold_on_rate_limit=False


What Triggers a Hold

Only a rate-limit / quota failure that carries a usable reset window parks the job.

SignalHeld?Reason
429 with Retry-After header (delta-seconds)✅ YesParked past that window + slack
429 with Retry-After header (HTTP-date, RFC 7231)✅ YesParsed via stdlib; parked past that instant + slack
429 with retry_after attribute on the exception✅ YesSame as above
429 message text like retry after 30 seconds✅ YesExtracted from the message
429 with no reset hint at all❌ NoNo usable window — falls through to today’s behaviour
Non-quota failure (validation, network, 5xx)❌ NoNot a rate-limit signal
A prior hold in the future, new (shorter) windowPrior winsA hold is never shortened by a later failure

Interplay with Incidents

The hold notice is not gated on alert_after_failures — a job configured to alert only after N failures would otherwise park silently and the operator would never learn it was held. Failure and recovery accounting still folds through the incident tracker for consistency. See Scheduler Incidents.

Interplay with Delivery

The “held until N” notice reuses the same de-duplicating incident/alert plumbing as deliver_on_failure, so a benched provider produces a single notice — not one alert per skipped tick. See Scheduler Delivery.

Persistence

hold_until is persisted only when set, so a benched job stays parked across process restarts and jobs that never hit a quota wall are byte-for-byte unchanged in their serialised form.


Configuration Options

Two fields on RunPolicy control the hold. See the full RunPolicy reference.

OptionTypeDefaultDescription
hold_on_rate_limitboolTruePark the job past the provider’s reset window on a rate-limit / quota failure instead of re-firing every tick. Set False to keep the prior fire-every-tick behaviour.
hold_slack_secondsfloat60.0Extra seconds added past the provider’s reset window when parking, so the job does not re-fire the instant the window reopens.

Best Practices

Keep the default on for recurring jobs

A nightly or hourly job that hits a shared quota is exactly what this protects against. Leave hold_on_rate_limit=True.

Bump hold_slack_seconds for providers with rounded reset instants

If you see a job re-fire and immediately re-park, the provider’s reset instant is rounded past Retry-After. Raise the slack:

python
from praisonai.scheduler import RunPolicy

policy = RunPolicy(hold_slack_seconds=180.0)
Disable only for one-shot jobs or a custom 429 wrapper

The prior fire-every-tick semantics are almost never what you want in production. Disable only when the job runs once, or when you already handle 429s yourself:

python
from praisonai.scheduler import RunPolicy

policy = RunPolicy(hold_on_rate_limit=False)
Trust the 24-hour cap

A bogus provider Retry-After can never park a job indefinitely — the hold is bounded to 24 hours, then the job becomes due again.


Related