Fluent REST client with circuit breaker, retry policies, certificate support, and object pooling. Provides resilient HTTP communication with configurable error handling and response mapping.
$ dotnet add package Myth.Rest
A modern, fluent REST client for .NET with enterprise-grade resilience patterns. Built for developers who value clean code, type safety, and minimal boilerplate.
Calling external APIs with HttpClient is verbose and error-prone. Manual JSON serialization, no retry logic, forgetting to dispose clients, no circuit breakers, complex authentication setup. Myth.Rest provides a fluent, type-safe API client with built-in retries, circuit breakers, pooled HttpClient management, and automatic JSON handling. Call APIs in one fluent chain, resilience patterns included.
HttpClient boilerplate everywhere. Manual JsonSerializer.Deserialize. No retry logic (network failures crash). Memory leaks from improper disposal. Authentication scattered. File uploads complex.
Fluent API: await _rest.Post("api/users").Body(user).ExecuteAsync<UserDto>(). Resilience built-in: Retry with exponential backoff, circuit breakers. Type-safe: Automatic JSON serialization/deserialization. HttpClient pooling: Proper resource management. Files: Upload/download with minimal code. Auth: Bearer tokens, certificates, custom headers.
90% less code vs raw HttpClient. Resilient: Automatic retry and circuit breaker. Type-safe: No manual JSON. Production-ready: Pooled clients, proper disposal.
dotnet add package Myth.Rest
var response = await Rest
.Create()
.Configure(config => config
.WithBaseUrl("https://api.github.com")
.WithHeader("User-Agent", "Myth.Rest"))
.DoGet("users/octocat")
.OnResult(result => result.UseTypeForSuccess<User>())
.OnError(error => error.ThrowForNonSuccess())
.BuildAsync();
var user = response.GetAs<User>();
var newUser = new CreateUserRequest {
Name = "John Doe",
Email = "john@example.com"
};
var response = await Rest
.Create()
.Configure(config => config
.WithBaseUrl("https://api.example.com")
.WithBearerAuthorization(token))
.DoPost("users", newUser)
.OnResult(result => result
.UseTypeFor<User>(HttpStatusCode.Created)
.UseTypeFor<ValidationError>(HttpStatusCode.BadRequest))
.OnError(error => error.ThrowForNonSuccess())
.BuildAsync();
var user = response.GetAs<User>();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRest(config => config
.WithBaseUrl("https://api.example.com")
.WithBearerAuthorization(builder.Configuration["ApiToken"])
.WithRetry());
var app = builder.Build();
public class UserService
{
private readonly IRestRequest _restClient;
public UserService(IRestRequest restClient)
{
_restClient = restClient;
}
public async Task<User> GetUserAsync(int id, CancellationToken cancellationToken = default)
{
var response = await _restClient
.DoGet($"users/{id}")
.OnResult(r => r.UseTypeForSuccess<User>())
.OnError(e => e.ThrowForNonSuccess())
.BuildAsync(cancellationToken);
return response.GetAs<User>();
}
}
builder.Services.AddRestFactory()
.AddRestConfiguration("github", config => config
.WithBaseUrl("https://api.github.com")
.WithHeader("User-Agent", "MyApp"))
.AddRestConfiguration("stripe", config => config
.WithBaseUrl("https://api.stripe.com")
.WithBearerAuthorization(stripeKey));
public class MultiApiService
{
private readonly IRestFactory _factory;
public MultiApiService(IRestFactory factory)
{
_factory = factory;
}
public async Task<Repository> GetGitHubRepoAsync(string owner, string repo)
{
var response = await _factory
.Create("github")
.DoGet($"repos/{owner}/{repo}")
.OnResult(r => r.UseTypeForSuccess<Repository>())
.BuildAsync();
return response.GetAs<Repository>();
}
public async Task<Charge> CreateStripeChargeAsync(ChargeRequest charge)
{
var response = await _factory
.Create("stripe")
.DoPost("charges", charge)
.OnResult(r => r.UseTypeForSuccess<Charge>())
.BuildAsync();
return response.GetAs<Charge>();
}
}
.Configure(config => config
.WithBaseUrl("https://api.example.com")
.WithTimeout(TimeSpan.FromSeconds(30))
.WithContentType("application/json")
.WithBearerAuthorization("your-token")
.WithHeader("X-Custom-Header", "value")
.WithBodySerialization(CaseStrategy.CamelCase)
.WithBodyDeserialization(CaseStrategy.SnakeCase))
// Bearer Token
.WithBearerAuthorization("your-token")
// Basic Authentication
.WithBasicAuthorization("username", "password")
// Basic Authentication (pre-encoded)
.WithBasicAuthorization("base64EncodedToken")
// Custom Authorization
.WithAuthorization("CustomScheme", "token")
builder.Services.AddHttpClient("MyApi", client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
builder.Services.AddRest(config => config
.WithHttpClientFactory(serviceProvider.GetRequiredService<IHttpClientFactory>(), "MyApi"));
For APIs that return interfaces, use type converters:
.Configure(config => config
.WithTypeConverter<IUser, UserDto>()
.WithTypeConverter<IProduct, ProductDto>())
.Configure(config => config
.WithLogging(logger, logRequests: true, logResponses: true))
.WithRetry() // 3 attempts, exponential backoff with jitter, server errors only
.WithRetry(retry => retry
.WithMaxAttempts(5)
.UseExponentialBackoffWithJitter(
baseDelay: TimeSpan.FromSeconds(1),
multiplier: 2.0,
maxDelay: TimeSpan.FromSeconds(30),
jitterRange: TimeSpan.FromMilliseconds(100))
.ForServerErrors()
.ForExceptions(typeof(TaskCanceledException), typeof(HttpRequestException)))
.WithRetry(retry => retry
.WithMaxAttempts(3)
.UseExponentialBackoff(TimeSpan.FromSeconds(1), multiplier: 2.0)
.ForServerErrors())
.WithRetry(retry => retry
.WithMaxAttempts(3)
.UseFixedDelay(TimeSpan.FromSeconds(2))
.ForStatusCodes(HttpStatusCode.ServiceUnavailable))
.WithRetry(retry => retry
.WithMaxAttempts(3)
.UseRandom(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5))
.ForServerErrors())
.WithRetry(retry => retry
.WithMaxAttempts(3)
.ForServerErrors() // 500, 502, 503, 504, 429
.ForStatusCodes(HttpStatusCode.RequestTimeout, HttpStatusCode.TooManyRequests)
.ForExceptions(typeof(TaskCanceledException)))
Prevent cascading failures in distributed systems:
.WithCircuitBreaker(options => options
.UseFailureThreshold(5)
.UseTimeout(TimeSpan.FromMinutes(1))
.UseHalfOpenRetryTimeout(TimeSpan.FromSeconds(30)))
Circuit Breaker States:
.DoGet("users")
.DoGet("users/123")
.DoGet("products?category=electronics&sort=price")
.DoPost("users", newUser)
.DoPost("orders", orderRequest)
.DoPut("users/123", updatedUser)
.DoPatch("users/123", partialUpdate)
.DoDelete("users/123")
var response = await Rest
.Create()
.Configure(config => config
.WithBaseUrl("https://api.example.com")
.WithRetry())
.DoDownload("files/document.pdf")
.OnError(error => error.ThrowForNonSuccess())
.BuildAsync();
// Save to file
await response.SaveToFileAsync("./downloads", "document.pdf", replaceExisting: true);
// Or get as stream
var stream = response.ToStream();
// Or get as bytes
var bytes = response.ToByteArray();
await using var fileStream = File.OpenRead("document.pdf");
var response = await Rest
.Create()
.Configure(config => config.WithBaseUrl("https://api.example.com"))
.DoUpload("files/upload", fileStream, "application/pdf")
.OnResult(r => r.UseTypeForSuccess<UploadResult>())
.BuildAsync();
var fileBytes = File.ReadAllBytes("image.jpg");
.DoUpload("files/upload", fileBytes, "image/jpeg")
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
var response = await _restClient
.DoUpload("files/upload", file)
.OnResult(r => r.UseTypeForSuccess<UploadResult>())
.BuildAsync();
return Ok(response.GetAs<UploadResult>());
}
.DoUpload("files/upload", file, settings => settings.UsePutAsMethod())
// Available: UsePostAsMethod(), UsePutAsMethod(), UsePatchAsMethod()
.OnResult(result => result
.UseTypeForSuccess<User>() // All 2xx codes
.UseTypeFor<ErrorResponse>(HttpStatusCode.BadRequest)
.UseTypeFor<ValidationErrors>(HttpStatusCode.UnprocessableEntity)
.UseEmptyFor(HttpStatusCode.NoContent))
For APIs that return different structures with the same status code:
.OnResult(result => result
.UseTypeFor<SuccessResponse>(
HttpStatusCode.OK,
body => body.status == "success")
.UseTypeFor<ErrorResponse>(
HttpStatusCode.OK,
body => body.status == "error"))
.OnResult(result => result
.UseTypeFor<ErrorResponse>(new[] {
HttpStatusCode.BadRequest,
HttpStatusCode.Conflict,
HttpStatusCode.UnprocessableEntity
}))
.OnResult(result => result.UseTypeForAll<ApiResponse>())
.OnResult(result => result.DoNotMap())
.OnError(error => error
.ThrowForNonSuccess() // Throw for any non-2xx status
.ThrowFor(HttpStatusCode.Unauthorized)
.NotThrowFor(HttpStatusCode.NotFound))
.OnError(error => error
.ThrowFor(HttpStatusCode.BadRequest,
body => body.errorCode == "VALIDATION_FAILED"))
Provide default responses for specific error scenarios:
.OnError(error => error
.UseFallback(HttpStatusCode.ServiceUnavailable, new {
message = "Service temporarily unavailable"
})
.UseFallback(HttpStatusCode.NotFound, new User {
Id = 0,
Name = "Unknown"
}))
.OnError(error => error
.ThrowForNonSuccess()
.NotThrowForNonMappedResult())
.WithCertificate(
certificatePath: "client-cert.pem",
keyPath: "client-key.pem",
keyPassword: "optional-password")
// From file
.WithCertificate(pfxPath: "client-cert.pfx", password: "password")
// From bytes
var pfxData = File.ReadAllBytes("client-cert.pfx");
.WithCertificate(pfxData: pfxData, password: "password")
// By thumbprint
.WithCertificateFromStore(
thumbprint: "A1B2C3D4E5F6...",
storeLocation: StoreLocation.CurrentUser)
// By subject name
.WithCertificateFromStoreBySubject(
subjectName: "CN=MyCert",
storeLocation: StoreLocation.LocalMachine)
var certificate = new X509Certificate2("client-cert.pfx", "password");
.WithCertificate(certificate)
.WithCertificate(options =>
{
options.Type = CertificateType.PemWithKey;
options.CertificatePath = "client-cert.pem";
options.KeyPath = "client-key.pem";
options.ValidateServerCertificate = false; // For development only
options.ServerCertificateValidationCallback = (sender, cert, chain, errors) =>
{
// Custom validation logic
return true;
};
})
public class UserRepository : IUserRepository
{
private readonly IRestRequest _client;
public UserRepository(IRestRequest client)
{
_client = client;
}
public async Task<User> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
var response = await _client
.DoGet($"users/{id}")
.OnResult(r => r.UseTypeForSuccess<User>())
.OnError(e => e
.ThrowForNonSuccess()
.UseFallback(HttpStatusCode.NotFound, new User { Id = id, Name = "Unknown" }))
.BuildAsync(cancellationToken);
return response.GetAs<User>();
}
public async Task<User> CreateAsync(CreateUserRequest request, CancellationToken cancellationToken = default)
{
var response = await _client
.DoPost("users", request)
.OnResult(r => r
.UseTypeFor<User>(HttpStatusCode.Created)
.UseTypeFor<ValidationErrorResponse>(HttpStatusCode.BadRequest))
.OnError(e => e.ThrowForNonSuccess())
.BuildAsync(cancellationToken);
return response.GetAs<User>();
}
public async Task<bool> UpdateAsync(int id, UpdateUserRequest request, CancellationToken cancellationToken = default)
{
var response = await _client
.DoPut($"users/{id}", request)
.OnResult(r => r.UseEmptyFor(HttpStatusCode.NoContent))
.OnError(e => e.ThrowForNonSuccess())
.BuildAsync(cancellationToken);
return response.IsSuccessStatusCode();
}
public async Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var response = await _client
.DoDelete($"users/{id}")
.OnResult(r => r.UseEmptyFor(HttpStatusCode.NoContent))
.OnError(e => e
.ThrowForNonSuccess()
.NotThrowFor(HttpStatusCode.NotFound))
.BuildAsync(cancellationToken);
return response.IsSuccessStatusCode();
}
}
Some APIs return 200 OK for all responses and use body fields to indicate errors:
var response = await Rest
.Create()
.Configure(config => config
.WithBaseUrl("https://legacy-api.com")
.WithRetry())
.DoGet("users")
.OnResult(result => result
.UseTypeFor<List<User>>(
HttpStatusCode.OK,
body => body.success == true))
.OnError(error => error
.ThrowFor(
HttpStatusCode.OK,
body => body.success == false)
.ThrowForNonSuccess())
.BuildAsync();
builder.Services.AddRestFactory()
.AddRestConfiguration("user-service", config => config
.WithBaseUrl("http://user-service:8080")
.WithCircuitBreaker(options => options
.UseFailureThreshold(5)
.UseTimeout(TimeSpan.FromMinutes(1)))
.WithRetry(retry => retry
.WithMaxAttempts(3)
.UseExponentialBackoffWithJitter(TimeSpan.FromSeconds(1))
.ForServerErrors()))
.AddRestConfiguration("order-service", config => config
.WithBaseUrl("http://order-service:8080")
.WithCircuitBreaker(options => options
.UseFailureThreshold(3)
.UseTimeout(TimeSpan.FromMinutes(2)))
.WithRetry(retry => retry
.WithMaxAttempts(2)
.UseFixedDelay(TimeSpan.FromSeconds(2))));
public class ProductService
{
private readonly IRestFactory _factory;
private readonly ILogger<ProductService> _logger;
public ProductService(IRestFactory factory, ILogger<ProductService> logger)
{
_factory = factory;
_logger = logger;
}
public async Task<Product> GetProductAsync(string productId)
{
try
{
var response = await _factory
.Create("catalog")
.DoGet($"products/{productId}")
.OnResult(r => r.UseTypeForSuccess<Product>())
.OnError(e => e
.ThrowForNonSuccess()
.UseFallback(HttpStatusCode.ServiceUnavailable, new Product
{
Id = productId,
Name = "Product Unavailable",
Available = false
}))
.BuildAsync();
_logger.LogInformation(
"Retrieved product {ProductId} in {ElapsedTime}ms with {Retries} retries",
productId,
response.ElapsedTime.TotalMilliseconds,
response.RetriesMade);
return response.GetAs<Product>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve product {ProductId}", productId);
throw;
}
}
}
Every response contains comprehensive metadata:
var response = await Rest.Create()...BuildAsync();
Console.WriteLine($"Status: {response.StatusCode}");
Console.WriteLine($"URL: {response.Url}");
Console.WriteLine($"Method: {response.Method}");
Console.WriteLine($"Elapsed Time: {response.ElapsedTime}");
Console.WriteLine($"Retries Made: {response.RetriesMade}");
Console.WriteLine($"Fallback Used: {response.FallbackUsed}");
Console.WriteLine($"Is Success: {response.IsSuccessStatusCode()}");
// Access results
var user = response.GetAs<User>(); // Typed result
var json = response.ToString(); // JSON string
var bytes = response.ToByteArray(); // Byte array
var stream = response.ToStream(); // Stream
dynamic dyn = response.DynamicResult; // Dynamic object
try
{
var response = await Rest.Create()...BuildAsync();
}
catch (NonSuccessException ex)
{
// HTTP error status codes
Console.WriteLine($"Status: {ex.Response.StatusCode}");
Console.WriteLine($"Content: {ex.Response}");
}
catch (NotMappedResultTypeException ex)
{
// No type mapping found for status code
Console.WriteLine($"Unmapped status: {ex.StatusCode}");
}
catch (DifferentResponseTypeException ex)
{
// Attempting to cast to wrong type
Console.WriteLine($"Expected: {ex.ExpectedType}, Actual: {ex.ActualType}");
}
catch (ParsingTypeException ex)
{
// JSON deserialization failed
Console.WriteLine($"Failed to parse: {ex.Content}");
}
catch (FileAlreadyExsistsOnDownloadException ex)
{
// File exists during download
Console.WriteLine($"File exists: {ex.FilePath}");
}
catch (NoActionMadeException)
{
// No HTTP action was defined
}
catch (CircuitBreakerOpenException ex)
{
// Circuit breaker is open
Console.WriteLine("Service circuit breaker is open");
}
Always Use Dependency Injection: Register REST clients as services for better testability and configuration management
Configure Retry Policies: Use retry policies in production for transient failure resilience
Implement Circuit Breakers: Prevent cascade failures in distributed systems
Use Named Configurations: Leverage the factory pattern when integrating with multiple APIs
Handle Errors Gracefully: Use fallbacks for non-critical operations
Leverage Strong Typing: Always map responses to strongly typed models
Configure Timeouts: Set appropriate timeouts based on your API characteristics
Enable Logging: Use structured logging for debugging and monitoring
Secure Sensitive Headers: Use specific authorization methods instead of manual headers for tokens
Use CancellationTokens: Always pass cancellation tokens to support request cancellation
Myth.Rest uses ObjectPool<RestBuilder> internally to minimize allocations and improve performance in high-throughput scenarios. The pooling is transparent and automatic.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.