// 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 } }