Credit free load test · Microsoft 365 Copilot
Scope: These results cover Copilot Studio's Standard Harness only—not GitHub Harness. The measured limits and performance should not be assumed to apply to GitHub Harness.
140,405 real messages pushed through a published agent and measured end to end. The documented quota is 100 generative messages a minute. It sustained 175 a minute, and every refusal arrived looking like a success. The headline 50,000 message run was performed twice, on different days, and cost nothing either time. A community project built with GitHub Copilot, shared under the MIT licence; not a Microsoft product or supported by Microsoft.
Every message in the corpus, grouped by how loaded the environment was during the busiest minute that message belonged to. Below about a minute the service answers almost everything. Above it, refusals climb and never recover. This one shape is the finding: the limit is arrival rate, and it has a hard knee.
A single generative prompt, "What is the best movie right now", sent through the Microsoft 365 Agents SDK as an authenticated Microsoft 365 Copilot licensed user. That billing path is charged at no cost, so every message here was credit free, and that was confirmed in the Power Platform admin centre after each campaign rather than assumed. Each turn opened its own conversation, modelling distinct users rather than one person asking repeatedly, and recorded the send time, the first service response, the finished answer and the full answer text.
The single most useful conclusion here, so it is worth isolating. This environment will absorb enormous daily volume with almost no failures. What breaks it is a crowd arriving inside the same minute.
Measured per clock hour across the sustained campaigns, against a documented cap of 2,000 messages an hour. Charted in full further down this page.
Every burst of exactly 1,000 simultaneous messages ever fired at this agent. In each of these, 1,000 users pressed send at the exact same instant, inside the same second, with no spacing whatsoever. This is the worst case the service can be handed.
One important nuance: this is a rate limit, not a concurrency limit. The same 1,000 messages fired at once held 1,000 connections open and got 194 answers; spread over one minute they held only 89 and got 193. Eleven times less concurrency, same result. The service counts messages per minute, not simultaneous users. Simultaneity only hurts because 1,000 people pressing send together is 1,000 messages in one minute. So more client capacity cannot help, and spreading arrivals over time is the only lever that works.
Per minute, and it replenishes continuously. The admission constant is not an allowance you exhaust. This is the most common misreading of the sizing table below, so it is measured directly: the largest number of answers ever delivered inside a sliding window of each width, across the whole corpus.
Three numbers that are easy to conflate. Burst depth is what a single instantaneous wave gets before the door shuts. Replenishment rate is what keeps arriving every minute thereafter. There is no longer-period cap: the documented 2,000 an hour is not enforced, and one run delivered .
And this is not a count of concurrent conversations. Steady-state concurrency during the sustained run was about 15 simultaneous generations, because each answer takes roughly 4.6 seconds. The service caps arrival rate, so concurrency is an outcome of latency rather than a setting. A burst only looks like a concurrency event because 1,000 people pressing send together is, by definition, 1,000 messages inside one minute.
The arithmetic looks contradictory only if there are two limits. There is one. Everything the environment does is the same number, roughly 180 answers a minute, multiplied or divided by time. 9,000 in an hour is 150 a minute, which is under it. 250 a minute is over it. The hour never had a budget of its own.
The column that settles it is answers a minute. Every message in the corpus is banded by the rolling 60 second load the environment was actually under when it was sent, then that band's delivered rate is measured.
Below the knee, answers a minute tracks offered a minute exactly and nothing is refused. Above it, the answers column flattens and stops responding to pressure entirely: offering 358 a minute delivers 181, and offering 1,050 a minute delivers 224. The surplus is discarded, not queued, which is the single most important operational point in this report. Pushing harder does not buy throughput, it only converts would-be answers into refusals.
The sizing question read the other way round. Pick the number of people you expect to press send inside the same minute, and read off the chance any one of them gets an answer rather than an error. Typical uses the median admission of ; the band is the worst and best admission ever measured, so it carries real run to run variance rather than implying false precision.
Read it as a per-person coin flip: at 500 simultaneous users each person has roughly a 36% chance of an answer and a 64% chance of the raw error dump. The number answered barely moves, because the environment admits a near constant number and refuses the rest. Everyone above the line gets refused in about 320 ms, faster than the roughly 4,000 ms a real answer takes, so a failed crowd fails quickly and loudly.
One account floods the agent while a completely different account asks the same agent the same question at a trivial six messages a minute. Both accounts were proven working alone against an idle environment first, so a refusal here cannot be an access problem.
Both predictions were written down before the run, as with E8.
Everything measured in this report is a pure generative answer with no tool invoked. That was deliberate, and by the end it was enforced: every tool was deleted from the agent and the live endpoint was probed before those runs to prove the planner was handed nothing. So the ~175 a minute figure is the ceiling on the agent thinking and replying, and nothing else.
If your agent calls tools, this is not the only ceiling you face. Each tool call goes out through a connector to a real API, and those carry their own independent throughput limits that stack on top of the environment budget measured here. Any one of them can bite first, so a tool-using agent can sit comfortably inside the generative quota and still fail.
| Ceiling | Roughly where it sits | Scope |
|---|---|---|
| Generative orchestration (measured here) | ~175 a minute | per Dataverse environment |
| Connector throttling | commonly 6,000 per 5 minutes per connection where the connector publishes nothing lower, but many publish far lower | per connection |
| Power Platform requests | 40,000 per 24 h on paid Power Platform licences, 6,000 on Microsoft 365 licences, 250,000 on the Copilot Studio base offer | per user per 24 h |
| The downstream service itself | Graph, Exchange and SharePoint each throttle per user and per tenant | per user / tenant |
1. Connector limits are per connection, not per environment. Every user of the agent sharing one connection shares that quota, so a connector can throttle at a load the generative budget would have absorbed comfortably. Each connector publishes its own figures on its Microsoft Learn reference page and they vary widely, so look up the specific ones your agent uses rather than assuming the default.
2. The per-user Power Platform entitlement has a trap in it. Copilot Studio counts API calls to Power Automate flows against the user's daily request allocation, and a user holding only a Microsoft 365 licence sits on the 6,000 per 24 hours tier, not 40,000. An agent that fires a flow on every turn burns that allocation quickly.
3. The two failure modes look completely different, and only one is visible to normal monitoring. Generative refusals, across all , arrive as HTTP 200 with no Retry-After and are invisible to anything watching status codes. Connector throttling behaves conventionally: HTTP 429 with a Retry-After, which standard retry logic and platform monitoring both understand. A tool-using agent fails in two different ways, and the one this report is about is the one your monitoring will miss.
Honesty label: the tool and connector limits in this block are documented, not measured by this project. Everything else in this report is measured. If tool throughput matters to your scenario it deserves its own test, because this harness deliberately removed tools in order to isolate the generative path.
Measured on answered turns only, so refusals and errors cannot flatter the numbers. A response has two distinct phases: the service acknowledges almost immediately, then spends the bulk of the time generating. Copilot Studio does not stream tokens on this transport, so the answer arrives complete in a single activity.
What the p numbers mean. A percentile is the value that a given share of messages came in under. p50 is the median: half of all answers were faster than this and half were slower. p90 means 90% were faster, so only the slowest 1 in 10 took longer. p99 means 99% were faster, so this is roughly the worst experience 1 user in 100 had. Averages hide the tail; percentiles are what you size a user expectation against. Read p99 as "almost nobody waited longer than this".
Conversation start is a further round trip of roughly 500 to 600 ms. It is excluded here because it is not a generative message and would inflate every figure.
Microsoft caps arrival rate, not concurrency. The two are linked by Little's Law: concurrent users equals arrival rate multiplied by response time. At a 4.6 second answer, 175 messages per minute is only about 13 users waiting at once.
Every bar to the right of the cap line is a documented limit that was not enforced. Only whole clock hours are shown.
Below roughly 175 messages per minute almost nothing is refused. Above it the refusal rate climbs steeply, because the service accepts a fixed number and rejects the surplus rather than queueing it.
This is the question the whole exercise turns on, so it is measured rather than assumed. A sweep line over every turn's send and completion timestamps reconstructs exactly how many requests were genuinely in flight at each instant. Two different things get called concurrency and they must not be confused: offered is how many the client had open at once, served is how many the service was actually generating.
Firing seven times more requests at once returns the same number of answers. That much is real. But peak generating equals the answered count exactly in every burst: 182, 192, 167, 166, 177. That identity is arithmetic, not a discovery. If every request starts in the same instant then every admitted request overlaps every other, so the peak simply is the answered count restated. For a burst this measure carries no extra information. It is only informative for paced traffic, where paced-200 answered 200 messages while never holding more than 24 at once.
Read the left column and the bars as different units. On the left is the whole wave: how many were sent, answered and refused, which always add up. The bars are instantaneous concurrency, the most that were ever open or generating at the same moment. The two only diverge in the largest wave, where 2,000 were sent but the client never managed to hold more than 1,419 open simultaneously, because opening the last few hundred sockets took longer than the first refusals took to come back. That gap is a limit of one laptop, not of the service.
An earlier version of this page claimed the service admits about 180 simultaneous generations and refuses the surplus. That was wrong, and the rate ladder disproves it. Each rung below holds a fixed arrival rate for three minutes, so concurrency stays low throughout.
rpm-400 was refused 55% of the time while never exceeding 57 concurrent requests, with only 51 ever generating at once. If a 180 concurrency ceiling existed, none of that should have been throttled. Meanwhile burst-500 ran 192 generations simultaneously with no concurrency related failure at all. Refusals at 51 concurrent, success at 192 concurrent: concurrency is not the thing being limited. The honest conclusion is that the concurrency ceiling was never found, because the rate budget always fires first. 192 is a demonstrated floor on the service's capability, not a measured limit.
The blunt answer is that about 175 get an answer and the rest get refused, which is roughly 88% of them. That number is worth stating plainly rather than softening. But the follow up matters more, because the obvious diagnosis is the wrong one: it is not the simultaneity that breaks it. Below, each burst is paired with the identical volume spread evenly across a full minute. If crowding were the cause, spreading it out would fix it.
A correction to an earlier version of this block. It read "spreading it out does not help", justified by the refusal rate at 2,000 moving only 91% to 87%. That was the wrong metric and it hid the effect. In absolute terms the same 2,000 messages served 177 people fired at once and 260 spread across the minute: 83 more people, a 47% improvement. A percentage against a denominator of 2,000 makes a real gain look like rounding error. At 1,000 the gain genuinely is marginal (+10), and E8's purpose-built repeat found none at all there (194 at once against 193 paced).
The mechanism is the most actionable thing in this report. A burst fires everyone at t=0, so capacity that frees up over the remaining 50-odd seconds has nobody left to serve because every refused caller has already been told no. A paced offer keeps arriving and harvests that replenishment, which is why 260 answered in 60 seconds is the highest throughput measured anywhere in this project, higher than any burst. The practical consequence: retrying refused callers is worth doing, because the budget is not spent, merely unclaimed.
None of which overturns the main finding. The service is still counting how many walked through in the last minute, not how many are standing at the door: 1,419 at once and 1,419 spread over a minute both fail, because both are ~1,419 a minute against a budget of roughly 175 to 200. Spreading arrivals over minutes is the lever that works; spreading them inside a single minute only recovers what a burst wastes.
There is a second cost that the pass/fail count hides. Under burst, the requests that do succeed get much slower, and the acknowledgement that normally lands in 146 ms stretches to 8.8 seconds. So a burst does not simply split the crowd into winners and losers. It also makes the winners queue.
Every genuine simultaneous wave in the dataset is below, detected from send timestamps rather than trusted from stage labels. The striking result is the middle column: the number admitted barely moves no matter how many are offered. A fresh 60 second window lets a roughly fixed number through and refuses the remainder, so the success rate of a burst of N is simply that constant divided by N.
That gives a predictive model rather than a lookup table, and it holds tightly across a seventyfold range of burst sizes. Read it in reverse to size a launch:
The practical ceiling for everyone succeeding is under 200. Across fresh windows the admitted count ranged from , so size a clean sweep against the worst case rather than the median: admissions vary run to run, and sizing to the median fails about half the time. One critical caveat the table cannot show: this all assumes the window is fresh. Waves that landed on an already busy 60 seconds admitted nobody at all, which is exactly what happens to the second burst inside a minute.
Almost not at all, and it is worth being precise about why. The budget is counted over a rolling 60 second window, so any spacing shorter than that leaves everybody inside the same accounting period. Below is the controlled version of that claim: the 60 second load is held constant down each row, and arrivals are split by how tightly packed they were within a 10 second neighbourhood. If crowding were the problem, the right hand columns should collapse.
They do not collapse. Across each row the success rate is broadly flat, so a wall of traffic and a steady trickle fare about the same once you have fixed how many arrived in the minute. The direct comparison says the same thing: 1,000 messages fired in 9 seconds returned 16.6% success, and the same 1,000 spread evenly across 59 seconds returned 17.6%. Ten seconds of jitter buys nothing.
The distinction that matters is whether spacing is within the crowd or between individuals. Everyone clicking inside a 5 to 10 second window is one event and fails as one. But users genuinely arriving 5 to 10 seconds apart is only 6 to 12 messages a minute, which is far below budget and succeeds indefinitely. To move the needle you have to spread a cohort over minutes:
Everything above this point was inferred from runs designed to answer other questions, so this experiment was built to test it directly. The cohort is fixed at exactly 1,000 messages and the only thing that changes is how wide a window they arrive in: one instant, then one, two, three, four and five minutes. Each rung is separated by a four minute cooldown, four times the width of the rolling window, so no rung can inherit a spent budget from its predecessor.
It is a prediction test rather than an exploration. Before running it the model said a fresh window admits about 182 and refuses the rest, so success should simply be 182 divided by the offered rate. The predicted column below was written down first.
Columns are the measured success rate for each rung. The line is what the admission model predicted before the run started. If the model were wrong, the line and the columns would diverge.
The rising success percentage is arithmetic and not very interesting. This is the actual result. Raw answered counts are not comparable across windows of different length, so the quantity to watch is answers per minute. It barely moves. The service hands out the same throughput no matter how the crowd is shaped, and everything asked of it above that line is refused.
Every refused message in every rung came back with the same body. Not a similar body, the identical template, varying only in the conversation id and the timestamp. It is worth seeing verbatim, because a client that only inspects HTTP status codes sees six thousand successful requests.
A refusal is decided far faster than an answer is generated, so a blocked user finds out quickly. The burst row is the exception, but that is 1,000 sockets queueing on the client rather than the service taking longer to say no: even its fastest refusal took 6.4 seconds because everything was stuck behind the same pile.
The refusal code is GenAIToolPlannerRateLimitReached, which reads like the agent's own tools are eating the budget. That was worth testing rather than arguing about, especially since this agent turned out to have had four tools enabled throughout every earlier run. They were deleted, the agent republished, and the removal verified against the live endpoint by asking the service to name the tools it had been handed. Then the same experiments were run again.
A burst of 1,000 reveals the admission constant in a single shot, so it is the sharpest available test. If tools mattered, the lower group would sit clearly above the upper one.
Identical cohort of 1,000, identical arrival windows, identical prompt, same agent. The only difference is the tools.
Because the constraint is messages a minute, population size on its own is meaningless. What matters is the population multiplied by how often each person asks something. A thousand people who ask one question an hour are trivial. A hundred people hammering it once a minute are not.
Read it against the worry directly: 1,419 users are completely fine provided they collectively stay under the budget, which means roughly one question per person every 8 minutes at the measured 175, or every 9.5 minutes at a safer 150. That is an entirely normal usage pattern for an assistant. What is not survivable is 1,419 people all asking inside the same minute, which is a launch-day or all-hands broadcast pattern, not organic use. And even that is fixable: a client side queue draining at 150 a minute clears all 1,419 in about 9.5 minutes with near zero refusals, which is precisely what the 50,000 message run did for eight and a half hours at a 0.3% refusal rate.
The 50,000 message run held about 15 users at once, not 50,000. That is arithmetic rather than a shortcoming: 150 messages a minute at a 4.6 second answer is roughly 12 people waiting, by Little's Law. The Little's Law column is an independent cross check. It matches the measured mean closely for paced runs, and diverges sharply for burst runs, because in a burst the client holds far more requests open than the service is working on. That gap is itself the finding.
One caveat on the peak column. The main sustained run reads a peak of 343 against a p95 of 16, which is entirely an artefact of the host waking from a nine hour sleep and dumping every abandoned request onto the wire at once. Cut at the suspend boundary, its true steady state peak is 25.
These two numbers look contradictory and are not. They are the same budget spent two ways, and neither is a concurrency limit. The gate admits roughly 175 generative messages per minute. Pace those evenly and, at a 4.6 second answer, about 15 are in flight at any instant. Dump them all at once and the whole minute's budget is granted in a single instant, so about 180 start generating together and the gate is then shut for the rest of the minute. Little's Law holds in both cases; only the arrival shape changed. 15 is one minute's budget spread out, 180 is the same budget spent at once. Neither is a cap on simultaneous connections, and neither is 50,000.
Send timestamps come from a monotonic clock, which restarts with every process. Pooling intervals from separate runs therefore overlays unrelated timelines and invents concurrency that never happened. An early pass of this analysis reported a peak of 1,434 for exactly that reason. Every figure on this page is computed within a single run.
Served peak is tautological for a burst. If every request starts in the same instant, the peak overlap of successful requests is just the number of successful requests, so it says nothing the answered count did not already say. An early draft of this page presented that figure as an independent measurement of a service concurrency ceiling. It is not one.
A stage is one block of traffic with a single arrival pattern. The names are shorthand for how the messages were released, which is the variable the whole test turns on.
Paced and burst are the core comparison. Both send the same number of messages inside the same 60 seconds. Burst fires every one in the same instant, so a burst of 2,000 opens 2,000 connections simultaneously. Paced releases them at a steady drip, so a paced 2,000 sends roughly 33 every second and never holds more than a few dozen open at a time. The point of running both is that they produce almost the same number of answers (177 against 260 at 2,000; 166 against 176 at 1,000), which is what proves the limit counts messages per minute rather than simultaneous connections. The visible difference is latency, not success: bursting cost 24,142 ms mean against 6,340 ms paced, and that extra time is queueing on the test machine, not the service being slower.
Latency is answered turns only. A stage that looks fast while heavily throttled is fast because a refusal returns in about a fifth of the time a real answer takes. Click a column heading to sort.
The same prompt was sent tens of thousands of times. If the service cached or returned a canned response, the answers would collapse into a handful of clusters. They do not.
This tab carries a 500 answer sample so the file stays small enough to email. The full text of every one of the is held in the harness database and can be exported to CSV on request. Nothing else in this report depends on it: every figure, table and chart here is self contained.
Read the share column against intent, not as a reliability figure. About a quarter of all messages were refused, but that is the point of the exercise rather than a fault: most experiments here exist to provoke refusals by deliberately overloading the environment. The 50,000 message run, which is the one shaped like real usage, was 99.74% answered.
Median rather than mean, because a small number of turns were in flight when the host machine suspended and their wall clock timings span the sleep.
Every refusal reads GenAIToolPlannerRateLimitReached, which sounds like a problem with the agent's tools rather than with generative answers. It is not. Every message in this test was a plain generative answer, no tool was ever invoked and no consent card was ever raised, yet this is the only refusal code the platform produced.
The control is the other documented throttle codes. If a separate generative answers budget were the thing being exhausted, OpenAIRateLimitReached is the code Microsoft documents for it. It never fired once.
None of the documented sentences are ever actually delivered. Microsoft's throttling errors article glosses this code as "The usage limit for generative orchestration has been reached", which is the correct meaning and confirms the quota applies per Dataverse environment. But searching the full response text of all recorded messages, that sentence appears zero times, as do "maximum number of generative answers", "This agent is currently unavailable" and even the shared tail "Please try again later". What arrives instead, times, is the raw dump An error has occurred. plus the bare code. Searching your logs for the documented wording finds nothing; the only reliable thing to match on is the error code itself.
The planner is the component that decides, on every generative turn, whether to answer directly or reach for a tool or knowledge source. It runs even when there is nothing to reach for, so it sits on the path of every generative answer and it is the thing whose budget runs out. The name describes the component that refused, not the reason it refused. Read it as "the environment's generative budget is spent", and note that the second agent proves the point: it was refused at 5.3 messages a minute, having exhausted nothing of its own.
The timings back this up. A refused turn is acknowledged in a median of 222 ms, barely different from a healthy turn's 116 ms, and only then refused, a median of 781 ms after sending and occasionally much later. The conversation is accepted before anything decides to refuse it, so the refusal comes from inside the orchestrator rather than from a gateway. That is the mechanical reason there is no 429 and no Retry-After to work with.
Not as an HTTP 429, and not with a Retry-After header. Every one of the refusals recorded here came back as an ordinary HTTP 200 message activity whose text carries the error. Anything checking status codes will record a throttle as a successful answer. Parse the body, not the status.
Neither is a service fault. 868 session start failures and all 17 timeouts came from the host machine going to sleep mid run, since fixed and covered by tests. The other 246 came from the client running out of local TCP sockets while opening 10,000 conversations at once, which is a laptop limit rather than a service limit.
All 67,651 messages, each one its own conversation, with the time it was sent, how long the service took to acknowledge it, how long the full answer took, and what came back. Click any row to read it. Everything is embedded in this file, so search and filtering work offline.
Ack is the time from sending to the service's first activity on the conversation, the point at which it has accepted the turn. Total is from sending to the finished answer. The gap between them is generation time, which is where nearly all of the latency lives. Response text is stored once per distinct answer and shown to 200 characters; refusals are reconstructed in full in the detail view, including the conversation id and timestamp the service embedded in them.
Every throttled row reads GenAIToolPlannerRateLimitReached, which sounds tool related. No tool was ever called in this test and the agent raised no consent cards. The planner runs on every generative turn whether or not there is a tool to reach for, so the code names the component that refused rather than the reason. See the Refusals tab, where the other documented codes are shown never firing at all.
Everything on this page comes from one SQLite database holding one row per message. This section explains exactly what was sent, how it reached the agent, and how each number was measured, so any figure here can be challenged on its method.
Deliberately an ordinary agent rather than a tuned one. Nothing here was built for the test, so the ceiling measured is the one a normal published agent runs into.
Two things follow from this. First, the test prompt is answered from general knowledge, so the three SharePoint sources were connected but never actually retrieved from during a measured turn. That is what isolates pure generative latency, and it is also the main limit on how far these numbers generalise: an agent that retrieves on every turn would be slower, though it would meet the same arrival rate ceiling. Second, because the customisation is so light, there is very little about this agent that could be tuned to make the ceiling move, which the tool removal experiment went on to confirm.
A single generative prompt, "What is the best movie right now", chosen because it is answered from the model's own knowledge. It triggers no SharePoint retrieval and no tool call, which isolates pure generative answer latency and keeps the run clear of every billed line item. A second prompt, "Who is Tom Hanks?", was sent at low volume as a control to check that latency tracked load rather than the wording of the question.
Every message opened its own conversation. That models distinct users rather than one person asking repeatedly, which matters because a shared conversation would let the service reuse context and would not represent 1,000 different people.
There is no public API that drives the literal Microsoft 365 Copilot Chat or Teams window. DirectToEngine as the licensed user is the closest supported equivalent and shares the same billing treatment and the same environment quota, so the limits measured here are the limits a Teams user hits. What it does not measure is Teams client side rendering time.
The obvious approach, driving the real chat window with headless Chromium, does not scale to this question. Fifty thousand browser contexts would need several terabytes of RAM. More importantly it would measure the browser, not the service. The SDK path uses a few hundred bytes per in-flight message, which is how a single laptop was able to hold 1,419 genuinely simultaneous requests open.
Timings use a monotonic clock, so a multi hour run is immune to wall clock adjustment, and a separate ISO wall clock stamp is kept for cross run analysis. One trap worth naming: Copilot Studio does not stream tokens, so the "time to first token" field arrives with the finished answer and is within 2 ms of total latency. The real acknowledgement is first activity minus send, which is what is reported here.
Traffic is built from stages. A stage sends a fixed number of messages with one arrival pattern, then a cooldown long enough that the next stage cannot inherit a spent budget. Stage names are decoded in full on the Stages tab. The two that matter most: burst fires every message in the same instant, paced releases the same count at a steady drip. Running both is what separates a rate limit from a concurrency limit.
This is the part that most monitoring gets wrong. A throttle does not arrive as an HTTP 429 or 503, and carries no Retry-After. It arrives as an ordinary HTTP 200 message activity whose body happens to contain an error code. A client checking status codes sees nothing but success. Detection therefore parses the response body of every turn, which is also why the harness stores full response text rather than just a length or a hash.
The whole 50,000 message campaign was run a second time, on a later day, so the headline is a repeated measurement rather than a single observation. Everything that could be held constant was: the same agent, the same environment, the same single user account, the same prompt, the same 150 messages a minute, and the same harness build. Only the date changed.
The two campaigns are kept separate rather than pooled. A sustained run configured for the full target opens a new campaign, and a smaller run attaches to the campaign before it as a top up, which is how the first campaign's interrupted tail is stitched back on. Without that rule a repeat run would silently double the headline total and make one 50,000 message result look like a 100,000 message one.
Both campaigns are trimmed the same way. Each is cut at its first gap of more than ten minutes between messages, which is where the host machine suspended, and the contiguous block of timeouts that were in flight at that moment is removed with it. Those failed because the laptop went to sleep, not because the service refused them. A timeout anywhere else in a run is a genuine failure and is deliberately left in the figures.
Repeating the campaign showed that the environment's capacity is not constant, so the analysis needed a way to find those stretches rather than hand-picking them. Every minute is scored, and a minute qualifies as degraded when the offered load was at or under the measured ceiling and yet more than one percent of it was refused. That load condition is what stops a dip from ever being an overload correctly refused. Consecutive qualifying minutes are grouped, short clean gaps inside an episode are bridged because recovery is ragged rather than instant, and an episode is reported only if it lasts three minutes or more. Latency is compared against the campaign's own clean minutes, so a generally slower day is not mistaken for a dip.
Each episode also carries a send reordering figure, which is what rules out the test client as the cause. Messages are dispatched in strict sequence, but a message's send time is stamped after the service accepts the conversation, so a slow accept lets turns land out of order. A stalled client resumes and fires its backlog in order; only variable service-side latency scrambles it. Comparing an episode against the campaign's clean baseline therefore separates a service that has slowed from a client that has stalled.
No Direct Line channel, because it consumes credits. No service principal or unlicensed identity, because neither qualifies for the no charge path. No Azure Load Testing, because a single 50,000 virtual user hour would cost roughly £3,900 and would return about 175 successes and 49,825 refusals, since the cap is server side. And no distributed 50,000 socket client, which is costed at about $0.80 and still awaiting approval; it would prove the client can hold the sockets, not that the service can answer them.
Generative answers are billed at no charge when the caller is an authenticated Microsoft 365 Copilot licensed user and the agent runs under that user's identity. The harness only ever uses that path. Direct Line, which does consume credits, is never used. The agent has no tools attached, so there are no agent action or tenant graph grounding charges either. Credit consumption is only ever reported at agent and tenant level in the Power Platform admin centre, so a zero delta there is the only valid confirmation.
That confirmation has now been made, twice. Copilot Credit consumption was checked in the Power Platform admin centre after the first campaign and again after the whole 50,000 was repeated, and no credits were consumed on either occasion, across all messages. The credit free claim in this report is therefore verified against the billing system itself, not merely predicted from the documented billing rules, and it held on a second independent run.
50,000 concurrent generative prompts is not achievable on default quotas. Roughly 175 would be answered and the rest refused. What was delivered instead is 50,182 messages in total, serialised over time at 150 per minute, 99.74% answered and credit free. That is the same workload spread across hours rather than fired in one second.
Timings use a monotonic clock, so a multi hour run is immune to wall clock adjustment. Every turn writes one row to SQLite with the send time, first activity, completion, both derived durations, the full response text and a SHA-256 hash of it. Clustering by that hash is what proves whether caching exists.