// A small client for the Slflo integration API, with the rules every connector needs:
// 401 stops the job; 429 waits Retry-After; 5xx, a lost connection or REQUEST_IN_PROGRESS
// backs off and retries with the same Idempotency-Key; any other 4xx is an error to fix.
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Slflo.Samples;
/// 401: TOKEN_INVALID, TOKEN_EXPIRED, TOKEN_REVOKED or COMPANY_SUSPENDED. Stop and alert.
public sealed class TokenRefusedException(string code, string message) : Exception($"{code}: {message}")
{
public string Code { get; } = code;
}
/// A refusal to fix on your side, or a call that kept failing.
public sealed class ApiErrorException(int status, JsonNode? error)
: Exception($"{status} {error?["code"]}: {error?["message"]}")
{
public int Status { get; } = status;
public string Code { get; } = error?["code"]?.GetValue() ?? "";
public JsonNode? Error { get; } = error;
}
public sealed class SlfloClient
{
private readonly HttpClient _http;
private readonly int _maxAttempts;
private readonly TimeSpan _firstBackOff;
public SlfloClient(string token, string baseUrl, int maxAttempts = 6, TimeSpan? firstBackOff = null)
{
_http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(30) };
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_maxAttempts = maxAttempts;
_firstBackOff = firstBackOff ?? TimeSpan.FromSeconds(1);
}
/// From SLFLO_TOKEN and SLFLO_BASE (default https://api.slflo.com/api/integration/v1).
public static SlfloClient FromEnvironment() => new(
Environment.GetEnvironmentVariable("SLFLO_TOKEN") ?? throw new InvalidOperationException("Set SLFLO_TOKEN"),
Environment.GetEnvironmentVariable("SLFLO_BASE") ?? "https://api.slflo.com/api/integration/v1");
public Task GetAsync(string path, IDictionary? query = null) =>
SendAsync(HttpMethod.Get, path, query, null, null);
public Task PostAsync(string path, object body, string idempotencyKey) =>
SendAsync(HttpMethod.Post, path, null, body, idempotencyKey);
public Task PutAsync(string path, object body, string idempotencyKey, IDictionary? query = null) =>
SendAsync(HttpMethod.Put, path, query, body, idempotencyKey);
private async Task SendAsync(HttpMethod method, string path, IDictionary? query, object? body, string? key)
{
var url = path.TrimStart('/') + (query is null ? "" : "?" + string.Join("&",
query.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(Convert.ToString(p.Value, System.Globalization.CultureInfo.InvariantCulture)!)}")));
var json = body is null ? null : JsonSerializer.Serialize(body);
var backOff = _firstBackOff;
for (var attempt = 1; ; attempt++)
{
var last = attempt == _maxAttempts;
using var request = new HttpRequestMessage(method, url);
if (json is not null) request.Content = new StringContent(json, Encoding.UTF8, "application/json");
if (key is not null) request.Headers.Add("Idempotency-Key", key);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request);
}
catch (Exception lost) when (lost is HttpRequestException or TaskCanceledException && !last)
{
// The answer was lost; the retry carries the same Idempotency-Key, so it can't apply twice.
Console.Error.WriteLine($"slflo: {method} {path}: {lost.Message}, backing off {backOff.TotalSeconds}s");
await Task.Delay(backOff);
backOff *= 2;
continue;
}
using (response)
{
var text = await response.Content.ReadAsStringAsync();
var status = (int)response.StatusCode;
if (response.IsSuccessStatusCode) return JsonNode.Parse(text)!;
var error = TryParse(text)?["error"];
var code = error?["code"]?.GetValue() ?? "";
var retryAfter = response.Headers.RetryAfter?.Delta;
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new TokenRefusedException(code, error?["message"]?.GetValue() ?? "");
if (response.StatusCode == HttpStatusCode.TooManyRequests && !last)
{
var wait = retryAfter ?? backOff;
Console.Error.WriteLine($"slflo: {method} {path}: 429 {code}, waiting {wait.TotalSeconds}s (Retry-After)");
await Task.Delay(wait);
continue;
}
if ((status >= 500 || code == "REQUEST_IN_PROGRESS") && !last)
{
var wait = retryAfter ?? backOff;
Console.Error.WriteLine($"slflo: {method} {path}: {status} {code}, backing off {wait.TotalSeconds}s");
await Task.Delay(wait);
backOff *= 2;
continue;
}
throw new ApiErrorException(status, error);
}
}
}
private static JsonNode? TryParse(string text)
{
try { return JsonNode.Parse(text); } catch (JsonException) { return null; } // a gateway's HTML
}
}