Skip to content

Commit 745ea99

Browse files
Return rate_limit_exceeded instead of insufficient_quota from LanguageModelRateLimitingPlugin. Closes #1911
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 4a509da commit 745ea99

3 files changed

Lines changed: 69 additions & 12 deletions

File tree

‎DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using System.Diagnostics;
66
using System.Globalization;
77
using System.Net;
8+
using System.Text;
9+
using System.Text.Json;
810
using DevProxy.Abstractions.Plugins;
911
using DevProxy.Abstractions.Proxy;
1012
using DevProxy.Plugins.Behavior;
@@ -165,4 +167,49 @@ public async Task RateLimitingPlusRetryAfter_ThrottledResponseCarriesRetryAfter(
165167
out _),
166168
"Retry-After header should be an integer seconds value.");
167169
}
170+
171+
[Fact]
172+
public async Task LanguageModelRateLimiting_Throttle_ReturnsOpenAIRateLimitError()
173+
{
174+
await using var origin = await FakeOrigin.StartAsync();
175+
var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
176+
177+
var plugin = new LanguageModelRateLimitingPlugin(
178+
SharedHttpClient,
179+
NullLogger<LanguageModelRateLimitingPlugin>.Instance,
180+
urls,
181+
ProxyConfig,
182+
PluginConfig.FromJson("""
183+
{ "promptTokenLimit": 10, "completionTokenLimit": 100, "resetTimeWindowSeconds": 300 }
184+
"""));
185+
186+
await using var proxy = await KestrelProxyHarness.StartAsync(
187+
origin.Host, [plugin]);
188+
using var client = proxy.CreateHttpClient();
189+
190+
// /echo returns the request body, so the usage below is read back as the
191+
// response's token usage. #1 drains the prompt token limit, #2 is throttled.
192+
const string requestBody = """
193+
{
194+
"model": "gpt-4o",
195+
"messages": [ { "role": "user", "content": "hi" } ],
196+
"usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
197+
}
198+
""";
199+
using var firstContent = new StringContent(requestBody, Encoding.UTF8, "application/json");
200+
using var first = await client.PostAsync(new Uri($"http://{origin.Host}/echo"), firstContent);
201+
using var secondContent = new StringContent(requestBody, Encoding.UTF8, "application/json");
202+
using var throttled = await client.PostAsync(new Uri($"http://{origin.Host}/echo"), secondContent);
203+
204+
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
205+
Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode);
206+
Assert.True(throttled.Headers.Contains("retry-after"));
207+
208+
using var json = JsonDocument.Parse(await throttled.Content.ReadAsStringAsync());
209+
var error = json.RootElement.GetProperty("error");
210+
Assert.Equal("rate_limit_exceeded", error.GetProperty("code").GetString());
211+
Assert.Equal("tokens", error.GetProperty("type").GetString());
212+
var message = error.GetProperty("message").GetString();
213+
Assert.StartsWith("Rate limit reached for gpt-4o on tokens per min (TPM): Limit 10, Used 10.", message, StringComparison.Ordinal);
214+
}
168215
}

‎DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
140140
ShouldThrottle,
141141
_resetTime
142142
));
143-
ThrottleResponse(e);
143+
ThrottleResponse(e, openAiRequest?.Model);
144144
state.HasBeenSet = true;
145145
}
146146
else
@@ -275,26 +275,33 @@ private ThrottlingInfo ShouldThrottle(IHttpRequest request, string throttlingKey
275275
Configuration.HeaderRetryAfter);
276276
}
277277

278-
private void ThrottleResponse(ProxyRequestArgs e)
278+
private void ThrottleResponse(ProxyRequestArgs e, string? model)
279279
{
280280
var headers = new List<MockResponseHeader>();
281-
var body = string.Empty;
282281
var request = e.ProxySession.Request;
282+
var retryAfterSeconds = (int)(_resetTime - DateTime.Now).TotalSeconds;
283+
284+
// Report the limit that's been exhausted, matching OpenAI's
285+
// tokens-per-minute rate limit error so that clients back off and retry
286+
var (limit, remaining) = _promptTokensRemaining <= 0 ?
287+
(Configuration.PromptTokenLimit, _promptTokensRemaining) :
288+
(Configuration.CompletionTokenLimit, _completionTokensRemaining);
289+
var used = limit - Math.Max(remaining, 0);
290+
var modelInfo = string.IsNullOrEmpty(model) ? string.Empty : $" for {model}";
283291

284-
// Build standard OpenAI error response for token limit exceeded
285292
var openAiError = new
286293
{
287294
error = new
288295
{
289-
message = "You exceeded your current quota, please check your plan and billing details.",
290-
type = "insufficient_quota",
296+
message = string.Create(CultureInfo.InvariantCulture, $"Rate limit reached{modelInfo} on tokens per min (TPM): Limit {limit}, Used {used}. Please try again in {retryAfterSeconds}s."),
297+
type = "tokens",
291298
param = (object?)null,
292-
code = "insufficient_quota"
299+
code = "rate_limit_exceeded"
293300
}
294301
};
295-
body = JsonSerializer.Serialize(openAiError, ProxyUtils.JsonSerializerOptions);
302+
var body = JsonSerializer.Serialize(openAiError, ProxyUtils.JsonSerializerOptions);
296303

297-
headers.Add(new(Configuration.HeaderRetryAfter, ((int)(_resetTime - DateTime.Now).TotalSeconds).ToString(CultureInfo.InvariantCulture)));
304+
headers.Add(new(Configuration.HeaderRetryAfter, retryAfterSeconds.ToString(CultureInfo.InvariantCulture)));
298305
if (request.Headers.Any(h => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)))
299306
{
300307
headers.Add(new("Access-Control-Allow-Origin", "*"));

‎skills/dev-proxy/references/test-llm-apps.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,19 @@ Use `LanguageModelRateLimitingPlugin` to test token quota handling.
166166
],
167167
"body": {
168168
"error": {
169-
"message": "Token quota exceeded. Please wait.",
170-
"type": "insufficient_quota",
171-
"code": "token_quota_exceeded"
169+
"message": "Rate limit reached on tokens per min (TPM). Please try again later.",
170+
"type": "tokens",
171+
"param": null,
172+
"code": "rate_limit_exceeded"
172173
}
173174
}
174175
}
175176
```
176177

177178
Use `@dynamic` for the retry-after header to auto-calculate seconds until reset.
178179

180+
With `whenLimitExceeded: "Throttle"`, the plugin returns a 429 with OpenAI's `rate_limit_exceeded` error (`type: "tokens"`) so clients back off and retry. To simulate a billing/quota error instead (`insufficient_quota`), use `Custom` with your own response.
181+
179182
### Scenario Configs
180183

181184
**Tight limits (stress testing):** `promptTokenLimit: 500, completionTokenLimit: 250, resetTimeWindowSeconds: 30`

0 commit comments

Comments
 (0)