<@ULVA73B9P>: For using `.map` on an `@task` where...
# ask-marvin
j
@Marvin: For using
.map
on an
@task
where the input array is huge, we might hit our API rate limit. Is there way to avoid that?
m
thought for 301.6 seconds
Yes—use tag-based concurrency limits with mapped tasks so only N run at a time, even if you map over a huge list. Two common patterns: 1) Cap concurrent API calls with a tag (recommended) - Tag the task and set a concurrency limit. This works across all flows/workers in your workspace.
Copy code
python
from prefect import flow, task
from prefect.task_runners import ThreadPoolTaskRunner
import requests

@task(tags=["external-api"])
def call_api(url: str):
    r = requests.get(url, timeout=30)
    r.raise_for_status()
    return r.json()

@flow(task_runner=ThreadPoolTaskRunner(max_workers=50))  # worker threads can be high
def fetch_all(urls: list[str]):
    futures = call_api.map(urls)          # submits many tasks
    return futures.result()               # but only N run at once due to the tag limit
Set the limit:
Copy code
prefect concurrency-limit create external-api 5
prefect concurrency-limit inspect external-api
prefect concurrency-limit ls
prefect concurrency-limit delete external-api
- Docs: How to limit concurrent task runs with tags - CLI ref: concurrency-limit CLI - Concept: Tag-based concurrency limits Notes: - This limits simultaneous API calls (e.g., 5 at a time). If your vendor enforces requests-per-second, pair the limit with a small sleep inside the task to shape QPS. - If you only need to throttle within a single flow run, you can also set the flow’s task runner lower, e.g.
ThreadPoolTaskRunner(max_workers=5)
. Tag limits are better when multiple flows/deployments share the same API. 2) Batch very large inputs to avoid creating too many task runs - Chunk the list; each task processes a small batch sequentially (optionally with a short sleep for strict QPS).
Copy code
python
from prefect import flow, task
import time, requests

def chunks(items, size):
    for i in range(0, len(items), size):
        yield items[i:i+size]

@task(tags=["external-api"])
def process_batch(batch: list[str], sleep_s: float = 0.0):
    out = []
    for url in batch:
        r = requests.get(url, timeout=30)
        r.raise_for_status()
        out.append(r.json())
        if sleep_s:
            time.sleep(sleep_s)  # shape QPS if needed
    return out

@flow
def fetch_batched(urls: list[str], batch_size: int = 100):
    batched = list(chunks(urls, batch_size))
    futures = process_batch.map(batched, sleep_s=[0.0]*len(batched))
    # flatten results
    return [item for batch in futures.result() for item in batch]
When to use which: - Need workspace-wide control and simplicity: tag-based limit on the API task. - Extremely large lists (>10k) and want to reduce orchestration overhead: batching, optionally combined with a tag limit on the batch task. - Strict requests-per-second: combine a small
sleep
with a tag limit (tag = concurrent cap; sleep = pace). If you share a bit more about your vendor’s exact rate policy (e.g., “max 5 concurrent, 100 req/min”), I can suggest concrete numbers for the limit and batch size/sleep.
j
@Marvin: I’m talking about the Prefect API rate limit, not a vendor’s rate limit. It seems to hit the rate limit since it calls the API to register the task? We have this at the moment:
Copy code
@flow(
    task_runner=ProcessPoolTaskRunner(
        max_workers=PARALLEL_PREPROCESSING_JOB_LIMIT,
    ),
)
Should this have worked to avoid my problem?
m
thought for 160.5 seconds
Got it — you’re concerned about Prefect Cloud/Server API rate limits, not your vendor’s API. Short answer: No — setting
ProcessPoolTaskRunner(max_workers=...)
does not, by itself, prevent hitting Prefect’s API rate limit when you map over a huge list. It caps how many tasks run concurrently, but a large
.map(...)
still submits a burst of task runs and their initial orchestration events (creation/initial state transitions/logs). That burst can exceed the Prefect API rate limit before execution is throttled by
max_workers
. What to do instead - Batch your map submissions to stage task-run creation - Or wrap work in a “batch task” so you create far fewer task runs - Optional: reduce per-item logging to cut API traffic further Example: stage task creation in batches
Copy code
python
from prefect import flow, task
from prefect.task_runners import ProcessPoolTaskRunner

def chunks(seq, size):
    for i in range(0, len(seq), size):
        yield seq[i:i+size]

@task
def preprocess(item):
    # your work here
    return item

@flow(task_runner=ProcessPoolTaskRunner(max_workers=PARALLEL_PREPROCESSING_JOB_LIMIT))
def main(items, submit_batch_size: int = 200):
    results = []
    for batch in chunks(items, submit_batch_size):
        futures = preprocess.map(batch)   # creates a manageable number of task runs
        results.extend(f.result() for f in futures)
    return results
Example: use a batch-processing task to reduce task-run count
Copy code
python
from prefect import flow, task
from prefect.task_runners import ProcessPoolTaskRunner

@task
def preprocess_batch(batch):
    # sequential inside the task to avoid many task runs
    return [preprocess_one(x) for x in batch]

@flow(task_runner=ProcessPoolTaskRunner(max_workers=PARALLEL_PREPROCESSING_JOB_LIMIT))
def main(items, batch_size: int = 500):
    batches = [items[i:i+batch_size] for i in range(0, len(items), batch_size)]
    futures = preprocess_batch.map(batches)
    return [y for batch in futures.result() for y in batch]
Notes - Tag-based concurrency limits won’t solve this specific issue either — they cap how many tasks run at once, but they don’t stop a large map from generating lots of orchestration events quickly. - If you’re connected to Prefect Cloud, lowering logging verbosity (e.g.,
PREFECT_LOGGING_LEVEL=WARNING
) can help reduce API traffic from logs, but batching is the main fix. Helpful refs: - Run work concurrently - Task runners If you can share: - Roughly how many items you map at once - Whether you’re on Cloud or a self-hosted server - The exact error (e.g., 429s) and when they appear (on submit vs. start/finish) …I can help size a good batch size and submission cadence for your case.
j
@Marvin: If I call
.map
multiple times in the same
@flow
, will they be on the same task runner for the flow? E.g. all in processes with a process task runner?
m
thought for 109.0 seconds
Yes. Within a single flow run, all calls to
.map()
use the same task runner instance defined on that flow. So if your flow is declared with
ProcessPoolTaskRunner(...)
, every mapped task submission in that flow will run in processes managed by that same pool. A few clarifications: - One task runner per flow run: Prefect duplicates and starts the flow’s configured task runner at flow start, and reuses it for all task submissions within that flow run. - Multiple
.map()
calls share that runner: they all submit work to the same pool (same processes/threads). - Subflows get their own runner: a nested
@flow
call creates a new task runner instance for that subflow (it does not inherit the parent’s pool), unless you explicitly configure the subflow with the same runner. Docs for background: Task runners
a
@Jesse hey, I ran into this a few weeks ago - after some discussion with the team, it turns out that task operations actually act over a websocket - unlikely that this will hit your rate limit (not sure what plan you're on)
however prefect actually has two rate limits, one for tasks/flows etc, and another one that they call their "orchestration limit" which task tag validation uses. this limit isn't documented, and you can't get usage from it, and is what caused us major slowdowns when submitting a lot of tasks
j
Oh fascinating, this is so appreciated. To clarify, what do you mean by task tag? That’s frustrating it’s not even listed as one of the 2 rate limits
I’ve created us a wrapper internally to use:
Copy code
def batch(size: int, sleep_time: float):
    """
    Decorator to add batched execution to a Prefect task.

    Adds a .batch() method to the task that internally calls .map()
    for each batch with a sleep between batches.

    Args:
        size: Number of items to process in each batch.
        sleep_time: Seconds to sleep between batches. Set to 0 for no sleep.

    The decorated task will have a .batch() method that:
    - Batches the iterable using iterate_batch()
    - Calls .map() for each batch
    - Collects all futures into a single PrefectFutureList
    - Sleeps for sleep_time seconds between batches
    - Returns PrefectFutureList[R] (same type as .map())

    Example:
        @batch(batch_size=50, sleep_time=5)
        @task
        def process_item(item: str) -> str:
            return item.upper()

        futures = process.batch(items, unmapped(other_arg))
    """

    def decorator(task_obj: Task) -> Task:
        @wraps(task_obj)
        def f(
            iterable,
            *args,
            **kwargs,
        ) -> PrefectFutureList[R]:
            # Construct a type that has `.result()` and so on
            all_futures: PrefectFutureList[R] = PrefectFutureList[R]()

            for batch in iterate_batch(iterable, size):
                batch_futures = task_obj.map(
                    batch,
                    # These will be parameters for the @task and
                    # iterable.
                    *args,
                    **kwargs,
                )
                all_futures.extend(batch_futures)

                # Give the Prefect API a small rest
                time.sleep(sleep_time)

            return all_futures

        # Make it available to call
        setattr(task_obj, "batch", f)

        return task_obj

    return decorator
and swapped from
.map
to
.batch
a
task tag - I mean
@task(tags=["foo"])
, then it'll make requests to 1) check if tag:foo has a concurrencly limit set, and if it does, it'll make requests to the API to get the concurrency info
j
Ah yeah, thought it might be that. Have you observed that if you use a task tag, then it doesn’t contribute to the orchestration limit? It’ll go “hey there’s no space for that tag, so I won’t do +1 to the rate limit”?
a
there was a recent bug fixed that I reported which was causing prefect to check this for every task even if there was no concurrency created for the tag: https://github.com/PrefectHQ/prefect/pull/20666
j
Legend, thank you, reading it now
a
I saw that it would make 2 requests (increment lease, decrement lease) for every function call, even if there was no concurrency. This meant that on a free account (250/minute api rate limit), it took something like 2 minutes to process 300 parallel threads
as the tag lookup would get 429'd by prefect, then wait, and try again!
j
Ah I see, so it was using the orchestration limit up unnecessarily
a
yep
Prefect claim that they're finishing work on updates to rate limits + observability to make it easier to see these limits. let's see if/when that happens
j
So in that scenario of if I did have a tag, it would use the rate limit to check if there was space, so I’d need to be careful of hitting a rate limit, but I’m actually trying to avoid the other rate limit
Haha I know what you mean. I was asking about observability for this beyond the webapp, and here’s some help I received:
It seems in the meantime you can view or download your rate limit usage with the following endpoints:
/api/accounts/{account_id}/rate-limits/usage
/api/accounts/{account_id}/rate-limits/usage/download
The
keys
parameter should be
logs-and-events
Link to the api: https://app.prefect.cloud/api/docs#get-/api/accounts/{account_id}/rate-limits/usage
a
right. in my understanding "I'm starting a new task" messages go through a websocket, not a single http request per task
j
Ah yes, you mentioned that, I need to let that sink in
a
and in fact when I debugged this with a free account, I only saw 5 http requests when starting 300 tasks
j
So good to know. I wonder though if sometimes it would fallback to a non-websocket if say the caller doesn’t support it, but I don’t think I’d be worried about that, but perhaps to confirm
a
re: your @batch decorator. We did something similar. We have a method that takes a generator of parameters, and slowly consumes it and submits them based on our "max limit"
in my understanding here, the main "caller" that is important is the prefect client library anyway. so I expect that it would "always" work?
🤔 1
it does feel a bit weird to have this rate limit anyway, if that's the case. I guess it catches things like if you have a dashboard or other external tool which makes requests to get status of flows/tasks
j
Nice nice. I was thinking if I want to maintain “wait for the submitted to finish” but decided I was more just worried about the submission, not the running, and we have a max limit on the task runner
I agree. I kind of get it since someone could accidentally or maliciously DoS with tasks submission. What we wished was that they’d go into a queue, but then, it’s being handled so that would be a hit? It’s an odd balancing act
Again, really appreciate your help
👍 1