├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── ThrottledWebApi.ClientDemo ├── Program.cs ├── README.md ├── ThrottledHttpClient.cs └── ThrottledWebApi.ClientDemo.csproj ├── ThrottledWebApi.IntegrationTests ├── IpRateLimitOptionsTests.cs ├── ThrottledWebApi.IntegrationTests.csproj └── ValuesControllerTests.cs ├── ThrottledWebApi.sln └── ThrottledWebApi ├── Controllers └── ValuesController.cs ├── Program.cs ├── Properties └── launchSettings.json ├── Startup.cs ├── ThrottledWebApi.csproj ├── appsettings.Development.json └── appsettings.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: changhuixu 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUnit 46 | *.VisualState.xml 47 | TestResult.xml 48 | nunit-*.xml 49 | 50 | # Build Results of an ATL Project 51 | [Dd]ebugPS/ 52 | [Rr]eleasePS/ 53 | dlldata.c 54 | 55 | # Benchmark Results 56 | BenchmarkDotNet.Artifacts/ 57 | 58 | # .NET Core 59 | project.lock.json 60 | project.fragment.lock.json 61 | artifacts/ 62 | 63 | # StyleCop 64 | StyleCopReport.xml 65 | 66 | # Files built by Visual Studio 67 | *_i.c 68 | *_p.c 69 | *_h.h 70 | *.ilk 71 | *.meta 72 | *.obj 73 | *.iobj 74 | *.pch 75 | *.pdb 76 | *.ipdb 77 | *.pgc 78 | *.pgd 79 | *.rsp 80 | *.sbr 81 | *.tlb 82 | *.tli 83 | *.tlh 84 | *.tmp 85 | *.tmp_proj 86 | *_wpftmp.csproj 87 | *.log 88 | *.vspscc 89 | *.vssscc 90 | .builds 91 | *.pidb 92 | *.svclog 93 | *.scc 94 | 95 | # Chutzpah Test files 96 | _Chutzpah* 97 | 98 | # Visual C++ cache files 99 | ipch/ 100 | *.aps 101 | *.ncb 102 | *.opendb 103 | *.opensdf 104 | *.sdf 105 | *.cachefile 106 | *.VC.db 107 | *.VC.VC.opendb 108 | 109 | # Visual Studio profiler 110 | *.psess 111 | *.vsp 112 | *.vspx 113 | *.sap 114 | 115 | # Visual Studio Trace Files 116 | *.e2e 117 | 118 | # TFS 2012 Local Workspace 119 | $tf/ 120 | 121 | # Guidance Automation Toolkit 122 | *.gpState 123 | 124 | # ReSharper is a .NET coding add-in 125 | _ReSharper*/ 126 | *.[Rr]e[Ss]harper 127 | *.DotSettings.user 128 | 129 | # JustCode is a .NET coding add-in 130 | .JustCode 131 | 132 | # TeamCity is a build add-in 133 | _TeamCity* 134 | 135 | # DotCover is a Code Coverage Tool 136 | *.dotCover 137 | 138 | # AxoCover is a Code Coverage Tool 139 | .axoCover/* 140 | !.axoCover/settings.json 141 | 142 | # Visual Studio code coverage results 143 | *.coverage 144 | *.coveragexml 145 | 146 | # NCrunch 147 | _NCrunch_* 148 | .*crunch*.local.xml 149 | nCrunchTemp_* 150 | 151 | # MightyMoose 152 | *.mm.* 153 | AutoTest.Net/ 154 | 155 | # Web workbench (sass) 156 | .sass-cache/ 157 | 158 | # Installshield output folder 159 | [Ee]xpress/ 160 | 161 | # DocProject is a documentation generator add-in 162 | DocProject/buildhelp/ 163 | DocProject/Help/*.HxT 164 | DocProject/Help/*.HxC 165 | DocProject/Help/*.hhc 166 | DocProject/Help/*.hhk 167 | DocProject/Help/*.hhp 168 | DocProject/Help/Html2 169 | DocProject/Help/html 170 | 171 | # Click-Once directory 172 | publish/ 173 | 174 | # Publish Web Output 175 | *.[Pp]ublish.xml 176 | *.azurePubxml 177 | # Note: Comment the next line if you want to checkin your web deploy settings, 178 | # but database connection strings (with potential passwords) will be unencrypted 179 | *.pubxml 180 | *.publishproj 181 | 182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 183 | # checkin your Azure Web App publish settings, but sensitive information contained 184 | # in these scripts will be unencrypted 185 | PublishScripts/ 186 | 187 | # NuGet Packages 188 | *.nupkg 189 | # NuGet Symbol Packages 190 | *.snupkg 191 | # The packages folder can be ignored because of Package Restore 192 | **/[Pp]ackages/* 193 | # except build/, which is used as an MSBuild target. 194 | !**/[Pp]ackages/build/ 195 | # Uncomment if necessary however generally it will be regenerated when needed 196 | #!**/[Pp]ackages/repositories.config 197 | # NuGet v3's project.json files produces more ignorable files 198 | *.nuget.props 199 | *.nuget.targets 200 | 201 | # Microsoft Azure Build Output 202 | csx/ 203 | *.build.csdef 204 | 205 | # Microsoft Azure Emulator 206 | ecf/ 207 | rcf/ 208 | 209 | # Windows Store app package directories and files 210 | AppPackages/ 211 | BundleArtifacts/ 212 | Package.StoreAssociation.xml 213 | _pkginfo.txt 214 | *.appx 215 | *.appxbundle 216 | *.appxupload 217 | 218 | # Visual Studio cache files 219 | # files ending in .cache can be ignored 220 | *.[Cc]ache 221 | # but keep track of directories ending in .cache 222 | !?*.[Cc]ache/ 223 | 224 | # Others 225 | ClientBin/ 226 | ~$* 227 | *~ 228 | *.dbmdl 229 | *.dbproj.schemaview 230 | *.jfm 231 | *.pfx 232 | *.publishsettings 233 | orleans.codegen.cs 234 | 235 | # Including strong name files can present a security risk 236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 237 | #*.snk 238 | 239 | # Since there are multiple workflows, uncomment next line to ignore bower_components 240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 241 | #bower_components/ 242 | 243 | # RIA/Silverlight projects 244 | Generated_Code/ 245 | 246 | # Backup & report files from converting an old project file 247 | # to a newer Visual Studio version. Backup files are not needed, 248 | # because we have git ;-) 249 | _UpgradeReport_Files/ 250 | Backup*/ 251 | UpgradeLog*.XML 252 | UpgradeLog*.htm 253 | ServiceFabricBackup/ 254 | *.rptproj.bak 255 | 256 | # SQL Server files 257 | *.mdf 258 | *.ldf 259 | *.ndf 260 | 261 | # Business Intelligence projects 262 | *.rdl.data 263 | *.bim.layout 264 | *.bim_*.settings 265 | *.rptproj.rsuser 266 | *- [Bb]ackup.rdl 267 | *- [Bb]ackup ([0-9]).rdl 268 | *- [Bb]ackup ([0-9][0-9]).rdl 269 | 270 | # Microsoft Fakes 271 | FakesAssemblies/ 272 | 273 | # GhostDoc plugin setting file 274 | *.GhostDoc.xml 275 | 276 | # Node.js Tools for Visual Studio 277 | .ntvs_analysis.dat 278 | node_modules/ 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2021 Changhui Xu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rate Limiting API Endpoints in ASP.NET Core 2 | 3 | Buy Me a Coffee at ko-fi.com 4 | 5 | ## [Medium Post 1: Rate Limiting API Endpoints in ASP.NET Core](https://medium.com/@changhuixu/rate-limiting-api-endpoints-in-asp-net-core-926e31428017) 6 | 7 | This post shows (1) an ASP.NET Core Web API demo project which limits inbound HTTP requests from the internet and (2) an integration test project which tests the Web API rate limit using a `TestServer` and an `HttpClient`. One of the integration tests shows an example approach to send concurrent API requests using a `Semaphore` in order to comply with the rate limit in the Web API application. 8 | 9 | ## [Medium Post 2: Throttling Concurrent Outgoing HTTP Requests in .NET Core](https://medium.com/@changhuixu/throttling-concurrent-outgoing-http-requests-in-net-core-404b5acd987b) 10 | 11 | This post will go over how to make concurrent outgoing HTTP requests _on the client side_. The goal is to let the HTTP Client send concurrent requests at the maximum allowed rate which is set by the server, for example, at a maximum rate of 2 requests per second. 12 | 13 | ## Solution Structure 14 | 15 | This solution contains 3 projects. 16 | 17 | 1 `ThrottledWebApi` 18 | 19 | - An ASP.NET Core Web API project 20 | - Contains one API endpoint: `/api/values/isPrime?number={number}` 21 | - The API endpoint is enforced with rate limit 22 | 23 | 2 `ThrottledWebApi.IntegrationTests` 24 | 25 | - An integration test project with an in-memory test server 26 | - An HTTP client is used to test against the API endpoint 27 | - The rate limiting effect in different scenarios are tested 28 | 29 | 3 `ThrottledWebApi.ClientDemo` 30 | 31 | - A .NET Core Console app with Dependency Injection, HttpClient 32 | - Throttling concurrent outgoing HTTP requests using a `semaphore` 33 | 34 | ## License 35 | 36 | Feel free to use the code in this repository as it is under MIT license. 37 | 38 | Buy Me a Coffee at ko-fi.com 39 | -------------------------------------------------------------------------------- /ThrottledWebApi.ClientDemo/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace ThrottledWebApi.ClientDemo 7 | { 8 | internal class Program 9 | { 10 | private static async Task Main() 11 | { 12 | var services = new ServiceCollection().AddHttpClient(); 13 | services.AddHttpClient(); 14 | var serviceProvider = services.BuildServiceProvider(); 15 | 16 | var client = serviceProvider.GetService(); 17 | var numbers = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0, 11, 13, 17, 19, 23, 29, 31, 41, 43, 1763 }; 18 | var results = await client.GetPrimeNumberResults(numbers); 19 | foreach (var result in results) 20 | { 21 | Console.WriteLine($"{result.Number} is a prime number? \t {result.IsPrime}."); 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ThrottledWebApi.ClientDemo/README.md: -------------------------------------------------------------------------------- 1 | # Throttling Concurrent Outgoing HTTP Requests in .NET Core 2 | 3 | - A .NET Core Console app with Dependency Injection, HttpClient 4 | - Throttling concurrent outgoing HTTP requests using a `semaphore` 5 | 6 | ## [Medium Post](https://medium.com/@changhuixu/throttling-concurrent-outgoing-http-requests-in-net-core-404b5acd987b) 7 | 8 | ## How to run the project 9 | 10 | 1. Go up one directory to `ThrottledWebApi` project. In a terminal, issue command `dotnet run` to start the Web API application. 11 | 12 | 2. In this folder, open a new terminal, run command `dotnet run` to check the Console ouput. ThrottledWebApi.ClientDemo 13 | -------------------------------------------------------------------------------- /ThrottledWebApi.ClientDemo/ThrottledHttpClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace ThrottledWebApi.ClientDemo 9 | { 10 | public interface IThrottledHttpClient 11 | { 12 | /// 13 | /// 14 | /// 15 | /// A list of integers 16 | /// Max number of concurrent requests 17 | /// per second or per n seconds 18 | /// 19 | Task GetPrimeNumberResults(List numbers, int requestLimit = 2, int limitingPeriodInSeconds = 1); 20 | } 21 | 22 | public class ThrottledHttpClient : IThrottledHttpClient 23 | { 24 | private readonly HttpClient _httpClient; 25 | private readonly string _baseUrl = @"http://localhost:5000/api"; 26 | 27 | public ThrottledHttpClient(HttpClient httpClient) 28 | { 29 | _httpClient = httpClient; 30 | } 31 | 32 | /// 33 | /// 34 | /// 35 | /// A list of integers 36 | /// Max number of concurrent requests 37 | /// per second or per n seconds 38 | /// 39 | public async Task GetPrimeNumberResults(List numbers, int requestLimit = 2, int limitingPeriodInSeconds = 1) 40 | { 41 | var throttler = new SemaphoreSlim(requestLimit); 42 | var tasks = numbers.Select(async n => 43 | { 44 | await throttler.WaitAsync(); 45 | 46 | var task = _httpClient.GetStringAsync($"{_baseUrl}/values/isPrime?number={n}"); 47 | _ = task.ContinueWith(async s => 48 | { 49 | await Task.Delay(1000 * limitingPeriodInSeconds); 50 | Console.WriteLine($"\t\t {n} waiting"); 51 | throttler.Release(); 52 | }); 53 | try 54 | { 55 | var isPrime = await task; 56 | Console.WriteLine($"{n}"); 57 | return new PrimeNumberResult(n, isPrime); 58 | } 59 | catch (HttpRequestException) 60 | { 61 | Console.WriteLine($"\t\t\t {n} error out"); 62 | return new PrimeNumberResult(n, "NA"); 63 | } 64 | }); 65 | return await Task.WhenAll(tasks); 66 | } 67 | } 68 | 69 | public class PrimeNumberResult 70 | { 71 | public long Number { get; set; } 72 | public string IsPrime { get; set; } 73 | 74 | public PrimeNumberResult() 75 | { 76 | } 77 | 78 | public PrimeNumberResult(long n, string isPrime) 79 | { 80 | Number = n; 81 | IsPrime = isPrime; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /ThrottledWebApi.ClientDemo/ThrottledWebApi.ClientDemo.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net5.0 6 | 7.3 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ThrottledWebApi.IntegrationTests/IpRateLimitOptionsTests.cs: -------------------------------------------------------------------------------- 1 | using AspNetCoreRateLimit; 2 | using Microsoft.AspNetCore.Mvc.Testing; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Options; 5 | using Microsoft.VisualStudio.TestTools.UnitTesting; 6 | 7 | namespace ThrottledWebApi.IntegrationTests 8 | { 9 | [TestClass] 10 | public class IpRateLimitOptionsTests 11 | { 12 | [TestMethod] 13 | public void CheckIpRateLimitOptions() 14 | { 15 | using var factory = new WebApplicationFactory(); 16 | var options = factory.Services.GetRequiredService>(); 17 | Assert.AreEqual(1, options.Value.GeneralRules.Count); 18 | var generalRule = options.Value.GeneralRules[0]; 19 | Assert.AreEqual("*:/api/*", generalRule.Endpoint); 20 | Assert.AreEqual("1s", generalRule.Period); 21 | Assert.AreEqual(2, generalRule.Limit); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ThrottledWebApi.IntegrationTests/ThrottledWebApi.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ThrottledWebApi.IntegrationTests/ValuesControllerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Mvc.Testing; 8 | using Microsoft.VisualStudio.TestTools.UnitTesting; 9 | 10 | namespace ThrottledWebApi.IntegrationTests 11 | { 12 | [TestClass] 13 | public class ValuesControllerTests 14 | { 15 | [DataTestMethod] 16 | [DataRow(1, "false")] 17 | [DataRow(2, "true")] 18 | [DataRow(11, "true")] 19 | [DataRow(12, "false")] 20 | public async Task TestWithDataSource(int n, string isPrime) 21 | { 22 | using var factory = new WebApplicationFactory(); 23 | var httpClient = factory.CreateClient(); 24 | var response = await httpClient.GetAsync($"api/values/isPrime?number={n}"); 25 | response.EnsureSuccessStatusCode(); 26 | var result = await response.Content.ReadAsStringAsync(); 27 | Assert.AreEqual(isPrime, result); 28 | } 29 | 30 | [TestMethod] 31 | public async Task ExpectExceptionWhenExceedRateLimit() 32 | { 33 | using var factory = new WebApplicationFactory(); 34 | var httpClient = factory.CreateClient(); 35 | var numbers = new List { 1, 12, 11 }; 36 | var allTasks = numbers.Select(n => Task.Run(async () => 37 | { 38 | var result = await httpClient.GetStringAsync($"api/values/isPrime?number={n}"); 39 | Console.WriteLine($"{n} is a prime number? {result}"); 40 | })).ToList(); 41 | async Task ConcurrentApiRequests() => await Task.WhenAll(allTasks); 42 | var e = await Assert.ThrowsExceptionAsync(ConcurrentApiRequests); 43 | Assert.AreEqual("Response status code does not indicate success: 429 (Too Many Requests).", e.Message); 44 | } 45 | 46 | [TestMethod] 47 | public async Task ExpectHttpHeadersWhenExceedRateLimit() 48 | { 49 | using var factory = new WebApplicationFactory(); 50 | var httpClient = factory.CreateClient(); 51 | httpClient.DefaultRequestHeaders.Clear(); 52 | var numbers = new List { 1, 12, 11 }; 53 | var allTasks = numbers.Select(n => Task.Run(async () => 54 | { 55 | var response = await httpClient.GetAsync($"api/values/isPrime?number={n}"); 56 | return new 57 | { 58 | Number = n, 59 | Headers = response.Headers.ToList() 60 | }; 61 | })).ToList(); 62 | 63 | var results = await Task.WhenAll(allTasks); 64 | 65 | // assert 66 | var retryHeaders = results.SelectMany(x => x.Headers).Where(x => x.Key == "Retry-After").ToList(); 67 | Assert.AreEqual(1, retryHeaders.Count); 68 | Assert.AreEqual("1", string.Join(", ", retryHeaders[0].Value)); 69 | var xRateLimitHeaders = results.SelectMany(x => x.Headers).Where(x => x.Key.StartsWith("X-Rate-Limit")).ToList(); 70 | Assert.AreEqual(6, xRateLimitHeaders.Count); 71 | 72 | // auxiliary method to print out all headers. 73 | foreach (var result in results) 74 | { 75 | Console.WriteLine($"\r\nHTTP Response Headers for number = {result.Number}:"); 76 | foreach (var (key, value) in result.Headers) 77 | { 78 | Console.WriteLine($"\t{key}: {string.Join(", ", value)}"); 79 | } 80 | } 81 | } 82 | /* Test Output 83 | 84 | TEST Server Started. 85 | 86 | HTTP Response Headers for number = 1: 87 | Retry-After: 1 88 | 89 | HTTP Response Headers for number = 12: 90 | X-Rate-Limit-Limit: 1s 91 | X-Rate-Limit-Remaining: 1 92 | X-Rate-Limit-Reset: 2019-09-17T18:51:02.4401731Z 93 | 94 | HTTP Response Headers for number = 11: 95 | X-Rate-Limit-Limit: 1s 96 | X-Rate-Limit-Remaining: 0 97 | X-Rate-Limit-Reset: 2019-09-17T18:51:02.4401731Z 98 | 99 | */ 100 | 101 | [TestMethod] 102 | public async Task TestWithSemaphoreSlim() 103 | { 104 | using var factory = new WebApplicationFactory(); 105 | var httpClient = factory.CreateClient(); 106 | var numbers = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0, 11, 13, 17, 19, 23, 29, 31, 41, 43, 1763, 7400854980481283 }; 107 | 108 | var throttler = new SemaphoreSlim(2); 109 | var tasks = numbers.Select(async n => 110 | { 111 | await throttler.WaitAsync(); 112 | 113 | var task = httpClient.GetStringAsync($"api/values/isPrime?number={n}"); 114 | _ = task.ContinueWith(async _ => 115 | { 116 | await Task.Delay(1000); 117 | throttler.Release(); 118 | }); 119 | try 120 | { 121 | var isPrime = await task; 122 | return new 123 | { 124 | Number = n, 125 | IsPrime = isPrime 126 | }; 127 | } 128 | catch (HttpRequestException) 129 | { 130 | return new 131 | { 132 | Number = n, 133 | IsPrime = "NA" 134 | }; 135 | } 136 | }); 137 | var results = await Task.WhenAll(tasks); 138 | 139 | // assert 140 | var expectedPrimeNumbers = new List { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 41, 43 }; 141 | var expectedNonPrimeNumbers = new List { 1, 4, 6, 8, 9, 10, 1763, 7400854980481283 }; 142 | CollectionAssert.AreEquivalent(expectedPrimeNumbers, results.Where(x => x.IsPrime == "true").Select(x => x.Number).ToList()); 143 | CollectionAssert.AreEquivalent(expectedNonPrimeNumbers, results.Where(x => x.IsPrime == "false").Select(x => x.Number).ToList()); 144 | Assert.AreEqual("NA", results.First(x => x.Number == 0).IsPrime); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /ThrottledWebApi.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29230.47 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ThrottledWebApi", "ThrottledWebApi\ThrottledWebApi.csproj", "{6D5B567A-BED1-4874-B60B-A8F8C4CFE248}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ThrottledWebApi.IntegrationTests", "ThrottledWebApi.IntegrationTests\ThrottledWebApi.IntegrationTests.csproj", "{31BA9603-08AF-4724-9E8C-FC6D87E1DACF}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ThrottledWebApi.ClientDemo", "ThrottledWebApi.ClientDemo\ThrottledWebApi.ClientDemo.csproj", "{202E8665-ACD6-497E-88A4-4FB822F8E8AC}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {6D5B567A-BED1-4874-B60B-A8F8C4CFE248}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {6D5B567A-BED1-4874-B60B-A8F8C4CFE248}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {6D5B567A-BED1-4874-B60B-A8F8C4CFE248}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {6D5B567A-BED1-4874-B60B-A8F8C4CFE248}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {31BA9603-08AF-4724-9E8C-FC6D87E1DACF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {31BA9603-08AF-4724-9E8C-FC6D87E1DACF}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {31BA9603-08AF-4724-9E8C-FC6D87E1DACF}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {31BA9603-08AF-4724-9E8C-FC6D87E1DACF}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {202E8665-ACD6-497E-88A4-4FB822F8E8AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {202E8665-ACD6-497E-88A4-4FB822F8E8AC}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {202E8665-ACD6-497E-88A4-4FB822F8E8AC}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {202E8665-ACD6-497E-88A4-4FB822F8E8AC}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {A91053CA-7466-4E02-88A1-5414AED230CD} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /ThrottledWebApi/Controllers/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace ThrottledWebApi.Controllers 5 | { 6 | [Route("api/[controller]")] 7 | [ApiController] 8 | public class ValuesController : ControllerBase 9 | { 10 | [HttpGet("isPrime")] 11 | public ActionResult GetIsPrime(long number) 12 | { 13 | try 14 | { 15 | return Ok(IsPrime(number)); 16 | } 17 | catch (ArgumentException e) 18 | { 19 | ModelState.AddModelError(nameof(number), e.Message); 20 | return BadRequest(ModelState); 21 | } 22 | } 23 | 24 | // TEST CASES: 25 | // 1,4,6,8,9,10 --> false; 26 | // 2,3,5,7,11 --> true; 27 | // 11,13,17,19,23,29,31,41,43 -> true; 28 | // 1763=41*43 --> false; 29 | // 7400854980481283=86028221*86028223 -> false; 30 | private static bool IsPrime(long number) 31 | { 32 | if (number <= 0) 33 | { 34 | throw new ArgumentException("Only valid positive numbers are supported."); 35 | } 36 | if (number == 1) 37 | { 38 | return false; 39 | } 40 | if (number == 2 || number == 3 || number == 5) 41 | { 42 | return true; 43 | } 44 | if (number % 2 == 0 || number % 3 == 0 || number % 5 == 0) 45 | { 46 | return false; 47 | } 48 | 49 | var boundary = (long)Math.Floor(Math.Sqrt(number)); 50 | var i = 6; 51 | while (i <= boundary) 52 | { 53 | if (number % (i + 1) == 0 || number % (i + 5) == 0) return false; 54 | i += 6; 55 | } 56 | return true; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ThrottledWebApi/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore; 2 | using Microsoft.AspNetCore.Hosting; 3 | 4 | namespace ThrottledWebApi 5 | { 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateWebHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IWebHostBuilder CreateWebHostBuilder(string[] args) => 14 | WebHost.CreateDefaultBuilder(args) 15 | .UseStartup(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ThrottledWebApi/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:5001/", 7 | "sslPort": 44369 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "launchUrl": "api/values/isPrime?number=123", 15 | "environmentVariables": { 16 | "ASPNETCORE_ENVIRONMENT": "Development" 17 | } 18 | }, 19 | "ThrottledWebApi": { 20 | "commandName": "Project", 21 | "launchBrowser": true, 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "applicationUrl": "http://localhost:5000/" 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /ThrottledWebApi/Startup.cs: -------------------------------------------------------------------------------- 1 | using AspNetCoreRateLimit; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | 7 | namespace ThrottledWebApi 8 | { 9 | public class Startup 10 | { 11 | public Startup(IConfiguration configuration) 12 | { 13 | Configuration = configuration; 14 | } 15 | 16 | public IConfiguration Configuration { get; } 17 | 18 | // This method gets called by the runtime. Use this method to add services to the container. 19 | public void ConfigureServices(IServiceCollection services) 20 | { 21 | services.AddControllers(); 22 | 23 | services.AddOptions(); 24 | services.AddMemoryCache(); 25 | services.Configure(Configuration.GetSection("IpRateLimiting")); 26 | services.AddSingleton(); 27 | services.AddSingleton(); 28 | services.AddSingleton(); 29 | services.AddHttpContextAccessor(); 30 | } 31 | 32 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 33 | public void Configure(IApplicationBuilder app, IHostEnvironment env) 34 | { 35 | if (env.IsDevelopment()) 36 | { 37 | app.UseDeveloperExceptionPage(); 38 | } 39 | app.UseHttpsRedirection(); 40 | 41 | app.UseIpRateLimiting(); 42 | 43 | app.UseRouting(); 44 | 45 | app.UseAuthorization(); 46 | 47 | app.UseEndpoints(endpoints => 48 | { 49 | endpoints.MapControllers(); 50 | }); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ThrottledWebApi/ThrottledWebApi.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /ThrottledWebApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ThrottledWebApi/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning" 5 | } 6 | }, 7 | "AllowedHosts": "*", 8 | "IpRateLimiting": { 9 | "EnableEndpointRateLimiting": true, 10 | "StackBlockedRequests": false, 11 | "RealIPHeader": "X-Real-IP", 12 | "ClientIdHeader": "X-ClientId", 13 | "HttpStatusCode": 429, 14 | "GeneralRules": [ 15 | { 16 | "Endpoint": "*:/api/*", 17 | "Period": "1s", 18 | "Limit": 2 19 | } 20 | ] 21 | } 22 | } 23 | --------------------------------------------------------------------------------