{"id":22122077,"url":"https://github.com/matteocontrini/plainhttp","last_synced_at":"2025-07-25T13:32:15.155Z","repository":{"id":38060912,"uuid":"202592180","full_name":"matteocontrini/PlainHttp","owner":"matteocontrini","description":"Easy HTTP client with support for serialization, proxies, testing, and more","archived":false,"fork":false,"pushed_at":"2024-01-24T17:14:39.000Z","size":84,"stargazers_count":20,"open_issues_count":0,"forks_count":3,"subscribers_count":3,"default_branch":"main","last_synced_at":"2024-12-01T12:52:41.692Z","etag":null,"topics":["dotnet","dotnet-core","http","httpclient"],"latest_commit_sha":null,"homepage":"","language":"C#","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/matteocontrini.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2019-08-15T18:30:04.000Z","updated_at":"2024-11-16T06:28:28.000Z","dependencies_parsed_at":"2022-09-03T05:40:20.952Z","dependency_job_id":"6a426ec9-5e2b-4776-9bc9-e4ba85cac610","html_url":"https://github.com/matteocontrini/PlainHttp","commit_stats":null,"previous_names":[],"tags_count":8,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/matteocontrini%2FPlainHttp","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/matteocontrini%2FPlainHttp/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/matteocontrini%2FPlainHttp/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/matteocontrini%2FPlainHttp/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/matteocontrini","download_url":"https://codeload.github.com/matteocontrini/PlainHttp/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":227581909,"owners_count":17789312,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["dotnet","dotnet-core","http","httpclient"],"created_at":"2024-12-01T15:17:15.622Z","updated_at":"2025-07-25T13:32:15.141Z","avatar_url":"https://github.com/matteocontrini.png","language":"C#","readme":"# PlainHttp [![NuGet](https://img.shields.io/nuget/v/PlainHttp?color=success)](https://www.nuget.org/packages/PlainHttp) [![License](https://img.shields.io/github/license/matteocontrini/PlainHttp?color=success)](https://github.com/matteocontrini/PlainHttp/blob/master/LICENSE)\n\nAn **easy HTTP client** for .NET 6+ with support for **serialization, deserialization, proxies, testing**, and more.\n\n## Features\n\n- Wraps `HttpClient` and provides a cleaner and easier interface\n- Supports any HTTP method\n- Per-request timeout with an actual `HttpRequestTimeoutException`\n- Per-request proxy with transparent pooling\n- Built-in serialization of objects to `JSON`/`XML`/URL-encoded, extensible to any other format\n- Built-in deserialization of `JSON`/`XML` responses\n- Download files to disk\n- Read responses with specific response encodings\n- Automatically enabled decompression of responses (all algorithms supported by .NET, i.e. gzip, DEFLATE, and Brotli)\n- Proper pooling and connection lifetime defaults to avoid DNS and socket exhaustion issues  \n- Allows to mock requests for unit testing\n- Heavily used in production by [@trackbotpro](https://github.com/trackbotpro/) to send millions of requests per day\n\n## Supported frameworks\n\nThis library targets .NET 6 (LTS) because it requires the `PooledConnectionLifetime` property on `SocketsHttpHandler`, introduced in .NET Core 2.2.\n\nThis makes sure that reusing the same `HttpClient` for a long time doesn't have [unintended consequences](https://github.com/dotnet/corefx/issues/11224) affecting DNS resolution. This library in fact keeps a pool of `HttpClient` instances that are never disposed.\n\nIn particular, the library keeps:\n\n- One `HttpClient` per request host\n- One `HttpClient` per proxy URI (including credentials)\n\nThere is currently no mechanism that disposes `HttpClient` instances that are unused, so if you use a lot of random proxies or many different hostnames, you might get into trouble. See [Custom `HttpClientFactory`](#custom-httpclientfactory) for instructions on how to override the default behavior.\n\n## Installation\n\nInstall the [PlainHttp](https://www.nuget.org/packages/PlainHttp) NuGet package:\n\n```\ndotnet add package PlainHttp\n```\n\n### Upgrading from 1.x to 2.x\n\nSee the release notes for [v2.0.0](https://github.com/matteocontrini/PlainHttp/releases/tag/v2.0.0).\n\n## Usage\n\n- [Basic usage](#basic-usage)\n- [Error handling](#error-handling)\n- [Request customization](#request-customization)\n- [Request serialization](#request-serialization)\n- [Response deserialization](#response-deserialization)\n- [Efficiently reading the response body](#efficiently-reading-the-response-body)\n- [Downloading files](#downloading-files)\n- [Proxies](#proxies)\n- [URL building](#url-building)\n- [Testing mode](#testing-mode)\n- [Custom serialization](#custom-serialization)\n- [Customizing `HttpClient` defaults](#customizing-httpclient-defaults)\n\n### Basic usage\n\nBasic `GET` request:\n\n```c#\nstring url = \"http://random.org\";\nIHttpRequest request = new HttpRequest(url);\nIHttpResponse response = await request.SendAsync();\nstring body = await response.ReadString();\n```\n\nAlso with `Uri`:\n\n```c#\nUri uri = new Uri(\"http://random.org\");\nIHttpRequest request = new HttpRequest(uri);\n```\n\n### Error handling\n\nChecking if the HTTP status code is in the `2xx` range:\n\n```c#\nIHttpResponse response = await request.SendAsync();\n\nif (!response.Succeeded)\n{\n    Console.WriteLine($\"Response status code is {response.StatusCode}\");\n}\nelse\n{\n    Console.WriteLine($\"Successful response in {response.ElapsedMilliseconds} ms\");\n}\n```\n\nAsserting that the HTTP status code is in the `2xx` range:\n\n```c#\nIHttpResponse response = await request.SendAsync();\nresponse.EnsureSuccessStatusCode(); // may throw HttpRequestException\n```\n\nEvery exception is wrapped in an `HttpRequestException`, from which `HttpRequestTimeoutException` is derived:\n\n```c#\ntry\n{\n    IHttpResponse response = await request.SendAsync();\n}\ncatch (HttpRequestException ex)\n{\n    if (ex is HttpRequestTimeoutException)\n    {\n        Console.WriteLine(\"Request timed out\");\n    }\n    else\n    {\n        Console.WriteLine(\"Something bad happened: {0}\", ex);\n        // PlainHttp.HttpRequestException: Failed request: [GET https://yyyy.org/] [No such host is known] ---\u003e System.Net.Http.HttpRequestException: No such host is known ---\u003e System.Net.Sockets.SocketException: No such host is known\n        // etc.\n    }\n}\n```\n\n### Request customization\n\nSetting **custom headers**:\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Headers = new Dictionary\u003cstring, string\u003e\n    {\n        // No user agent is set by default\n        { \"User-Agent\", \"PlainHttp/1.0\" }\n    }\n};\n```\n\nRequest a **specific HTTP version** to be used. If it's not supported, the default [`HttpVersionPolicy`](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpversionpolicy) applies (downgrade to a lower version).\n\n```csharp\nIHttpRequest request = new HttpRequest(url)\n{\n    Version = new Version(2, 0) // HTTP/2\n};\n```\n\n**Custom timeout** (by default no timeout is set):\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Timeout = TimeSpan.FromSeconds(10)\n};\n```\n\n### Request serialization\n\n`POST` request with **URL-encoded payload**:\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Post,\n    Payload = new FormUrlEncodedPayload(new\n    {\n        hello = \"world\",\n        buuu = true\n    })\n};\n```\n\n`POST` request with **JSON payload** (powered by `System.Text.Json`):\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Post,\n    Payload = new JsonPayload(new\n    {\n        hello = \"world\"\n    })\n};\n```\n\nYou can pass `JsonSerializerOptions` with the second argument of `JsonPayload`.\n\nIf you already have a JSON-serialized string, just pass it directly:\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Post,\n    Payload = new JsonPayload(\"{ \\\"key\\\": true }\")\n};\n```\n\n`POST` request with **XML payload** (powered by `System.Xml.Serialization.XmlSerializer`):\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Post,\n    Payload = new XmlPayload(new\n    {\n        something = \"web\"\n    })\n};\n```\n\nYou can pass XML serialization options with the second argument of `XmlPayload` (`XmlWriterSettings`). If you already have an XML-serialized string, just pass it directly.\n\nNote that the `XmlPayload` implementation will use the `UTF-8` encoding, which is normally not the default in .NET. If you have different requirements you should pass an already-serialized string or implement a [custom payload type](#custom-serialization).\n\n`POST` request with **plain text payload**:\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Post,\n    Payload = new PlainTextPayload(\"plain text\")\n};\n```\n\n### Response deserialization\n\nTo read the response body **as a string**:\n\n```c#\nstring body = await response.ReadString();\n```\n\nOptionally, you can specify the **encoding** to use:\n\n```c#\nstring body = await response.ReadString(Encoding.GetEncoding(\"ISO-8859-1\"));\n```\n\nTo read the body **as a stream**:\n\n```c#\nStream stream = await response.ReadStream();\n```\n\nNote that when using `ReadStream` the response message is not automatically disposed, so you must take care of disposing it manually when you're done with it.\n\nTo deserialize the response **as JSON**:\n\n```c#\nResponseDTO content = await response.ReadJson\u003cResponseDTO\u003e();\n```\n\nTo deserialize the response **as XML**:\n\n```c#\nResponseDTO content = await response.ReadXml\u003cResponseDTO\u003e();\n```\n\nTo read the body **as a byte array**:\n\n```c#\nbyte[] bytes = await response.ReadBytes();\n```\n\n### Efficiently reading the response body\n\nBy default, the full response body is loaded in memory during the `SendAsync` call. This means that when calling the various `Read*` methods, the response body is already fully downloaded and is thereefore read from a memory stream.\n\nTo change this, you can set the `HttpCompletionOption` request option to `HttpCompletionOption.ResponseHeadersRead` (from `System.Net.Http`):\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Get,\n    HttpCompletionOption = HttpCompletionOption.ResponseHeadersRead\n};\n```\n\nNow, when you call methods such as `ReadString` or `ReadJson`, the response body will be streamed from the socket as it arrives.\n\nThe library will also take care of **respecting the timeout you specified in the request**, calculating how much time is left to read the response.\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Get,\n    HttpCompletionOption = HttpCompletionOption.ResponseHeadersRead,\n    Timeout = TimeSpan.FromSeconds(10)\n};\n\n// This call returns immediately after reading the response headers\nIHttpResponse response = await request.SendAsync();\n\nConsole.WriteLine($\"Reading the headers took {response.ElapsedMilliseconds} ms\");\n\n// This call will proceed with reading the HTTP response body from the socket\n// and will throw HttpRequestTimeoutException if the response body is not\n// fully read within 10 total seconds\nstring body = await response.ReadString();\n\nConsole.WriteLine($\"Reading the headers+body took {response.ElapsedMilliseconds} ms in total\");\n```\n\nThe exception is if you use the `ReadStream` method: in that case PlainHttp cannot enforce a timeout when reading from that stream outside the library.\n\nYou also must take care of disposing the response manually when using `ReadStream` or if you don't read the response body at all:\n\n```csharp\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Get,\n    HttpCompletionOption = HttpCompletionOption.ResponseHeadersRead,\n    Timeout = TimeSpan.FromSeconds(10)\n};\n\n// You MUST dispose the response manually\n// when using HttpCompletionOption.ResponseHeadersRead and ReadStream(),\n// or if you don't read the response body at all\nusing IHttpResponse response = await request.SendAsync();\n\nStream stream = await response.ReadStream();\n// The timeout is not enforced if you read from `stream` here\n```\n\nIn all other cases (any other `Read*` method), responses are **always disposed automatically** after reading the response body, also in case of errors.\n\nA note on XML deserialization: the `ReadXml` method uses `XmlSerializer`, which is not asynchronous. Therefore, the response body is unfortunately always fully read in memory (asynchronously) before deserializing it, no matter the `HttpCompletionOption` setting.\n\n### Downloading files\n\nYou can use the `HttpCompletionOption.ResponseHeadersRead` option to efficiently download files to disk:\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Get,\n    HttpCompletionOption = HttpCompletionOption.ResponseHeadersRead\n};\n\nIHttpResponse response = await request.SendAsync();\n\nawait response.DownloadFileAsync(\"video.mp4\");\n```\n\n### Proxies\n\nYou can set a **custom proxy per request**:\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Proxy = new Uri(\"http://example.org:3128\")\n};\n```\n\n**Proxy credentials** are supported and are automatically parsed from the URI:\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Proxy = new Uri(\"http://user:pass@example.com:3128\")\n};\n```\n\nNote that due to the implementation of proxies in .NET, proxy credentials are only sent from the second request onwards and only if the proxy responded with *407 Proxy Authentication Required*. See [this issue](https://github.com/dotnet/runtime/issues/66244) for more details.\n\n### URL building\n\nThis library includes the `Flurl` URL builder as a dependency. Some `Flurl`-provided utilities are used internally but you can also use it to [build URLs](https://flurl.dev/docs/fluent-url/) in an easier way (thanks Todd Menier!):\n\n```c#\nstring url = \"http://random.org\"\n    .SetQueryParam(\"locale\", \"it\")\n    .SetQueryParam(\"timestamp\", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());\n```\n\n### Testing mode\n\n**Unit testing HTTP requests** is easy with PlainHttp. You can enqueue HTTP responses that will be dequeued in sequence.\n\nThis mechanism is \"async safe\": the `TestingMode` property is static but wrapped in to an `AsyncLocal` instance, so that you can run your tests in parallel.\n\n```c#\n// Run this once\nTestingMode http = new TestingMode();\nHttpRequest.SetTestingMode(http);\n\n// Then enqueue HTTP responses\nHttpResponseMessage msg = new HttpResponseMessage()\n{\n    StatusCode = (HttpStatusCode)200,\n    Content = new StringContent(\"oh hello\")\n};\n\nhttp.RequestsQueue.Enqueue(msg);\n\n// Then send your requests normally, in the same async context\n```\n\n### Custom serialization\n\nYou can implement your own custom serializer by implementing the `IPayload` interface.\n\nFor example, here's how you can use `Newtonsoft.Json` instead of `System.Text.Json`:\n\n```c#\npublic class NewtonsoftJsonPayload : IPayload\n{\n    private readonly object payload;\n    private readonly JsonSerializerSettings? settings;\n    \n    public NewtonsoftJsonPayload(object payload)\n    {\n        this.payload = payload;\n    }\n\n    public NewtonsoftJsonPayload(object payload, JsonSerializerSettings settings) : this(payload)\n    {\n        this.settings = settings;\n    }\n\n    public HttpContent Serialize()\n    {\n        return new StringContent(\n            content: JsonConvert.SerializeObject(payload, settings),\n            encoding: Encoding.UTF8,\n            mediaType: \"application/json\"\n        );\n    }\n}\n```\n\nThen use it like this:\n\n```c#\nIHttpRequest request = new HttpRequest(url)\n{\n    Method = HttpMethod.Post,\n    Payload = new NewtonsoftJsonPayload(new\n    {\n        something = \"hello\"\n    })\n};\n```\n\n### Customizing `HttpClient` defaults\n\nYou can customize how `HttpClient`s and the underlying `SocketsHttpHandler` are created by changing the static `HttpClientFactory` property.\n\nThe default factory provides some level of customization, which you can pass to the constructor. For example:\n\n```c#\nHttpRequest.HttpClientFactory = new HttpClientFactory(new HttpClientFactory.HttpHandlerOptions\n{\n    IgnoreCertificateValidationErrors = true\n});\n```\n\nThese options will apply to both proxied and non-proxied `HttpClient`s. You can however choose different settings for proxied and non-proxied clients:\n\n```c#\nHttpRequest.HttpClientFactory = new HttpClientFactory(\n    // Normal requests\n    new HttpClientFactory.HttpHandlerOptions\n    {\n        IgnoreCertificateValidationErrors = true\n    },\n    // Proxied requests\n    new HttpClientFactory.HttpHandlerOptions\n    {\n        IgnoreCertificateValidationErrors = false\n    }\n);\n```\n\nThese are all the available options with their defaults:\n\n```c#\npublic record HttpHandlerOptions\n{\n    public TimeSpan PooledConnectionLifetime { get; init; } = TimeSpan.FromMinutes(10);\n    public TimeSpan PooledConnectionIdleTimeout { get; init; } = TimeSpan.FromMinutes(1);\n    public TimeSpan ConnectTimeout { get; init; } = Timeout.InfiniteTimeSpan;\n    public DecompressionMethods AutomaticDecompression { get; init; } = DecompressionMethods.All;\n    public SslProtocols EnabledSslProtocols { get; init; } = SslProtocols.None;\n    public bool IgnoreCertificateValidationErrors { get; init; }\n    public bool AllowAutoRedirect { get; set; } = true;\n}\n```\n\nThe meanings of these options (which usually map to `SocketsHttpHandler` properties) are the following:\n\n- `PooledConnectionLifetime`: the maximum lifetime of a connection in the pool.\n- `PooledConnectionIdleTimeout`: the maximum idle time of a connection in the pool. If a connection is idle for more than this time, it will be closed.\n- `ConnectTimeout`: the timeout for establishing a connection to the server.\n- `AutomaticDecompression`: the decompression methods to use for the response body. By default, all methods (gzip, DEFLATE and Brotli) are enabled.\n- `EnabledSslProtocols`: the SSL/TLS protocols to use. By default, the system default is used.\n- `IgnoreCertificateValidationErrors`: whether to ignore certificate validation errors.\n- `AllowAutoRedirect`: whether redirect responses should be automatically followed. By default, redirects are followed.\n\nNote that when applied to proxied clients these options will apply to the connection to the proxy server itself. \n\n### Custom `HttpClientFactory`\n\nIf the above options aren't enough or you want more control, you can create your own factory implementation and set it to the static `HttpClientFactory` property:\n\n```c#\nHttpRequest.HttpClientFactory = new MyHttpClientFactory();\n```\n\nThe custom factory must implement the [`IHttpClientFactory`](https://github.com/matteocontrini/PlainHttp/blob/main/PlainHttp/IHttpClientFactory.cs) interface. The default factory implementation can be found [here](https://github.com/matteocontrini/PlainHttp/blob/main/PlainHttp/HttpClientFactory.cs).\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmatteocontrini%2Fplainhttp","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmatteocontrini%2Fplainhttp","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmatteocontrini%2Fplainhttp/lists"}