/* * Copyright 2026 Flix Authors * * Use of this source code is governed by the Apache 2.0 license * that can be found in the LICENSE.md file. */////// Effect for sending HTTP requests, and its functions.///pubmod Net.Http {use IoError.ErrorKinduse IoError.IoErroruse Logger.Severityuse Net.Httpuse Net.HttpLayeruse Net.HttpRequestuse Net.HttpRequest.HttpRequestuse Net.HttpResponseuse Net.HttpResponse.HttpResponseuse Net.RetryDecisionuse Net.RetryOutcomeuse RichString.{text, bold, cyan, green, yellow, red, gray}use Time.Clockuse Time.Durationuse Time.Duration.toMillis////// Effect for sending HTTP requests.////// Supports both `http://` and `https://` URLs.///pubeffHttp {/// Sends the HTTP request `req` and returns its response.defrequest(req: HttpRequest): Result[IoError, HttpResponse] }////// Sends the given request `req` via the `Http` effect.///pubdefsend(req: HttpRequest): Result[IoError, HttpResponse] \ Http =Http.request(req)////// Sends a GET request to the given `url`.///pubdefget(url: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.get(url))////// Sends a HEAD request to the given `url`.///pubdefhead(url: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.head(url))////// Sends an OPTIONS request to the given `url`.///pubdefoptions(url: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.options(url))////// Sends a POST request to the given `url` with no body.///pubdefpost(url: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.post(url))////// Sends a POST request to the given `url` with the given `body`.///pubdefpostWith(url: String, body: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.postWith(url, body))////// Sends a PUT request to the given `url` with no body.///pubdefput(url: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.put(url))////// Sends a PUT request to the given `url` with the given `body`.///pubdefputWith(url: String, body: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.putWith(url, body))////// Sends a PATCH request to the given `url` with no body.///pubdefpatch(url: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.patch(url))////// Sends a PATCH request to the given `url` with the given `body`.///pubdefpatchWith(url: String, body: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.patchWith(url, body))////// Sends a DELETE request to the given `url`.///pubdefdelete(url: String): Result[IoError, HttpResponse] \ Http =send(HttpRequest.delete(url))////// Handles the `Http` effect of the given function `f`.////// Re-interprets the `Http` effect using the `IO` effect.///pubdefhandle(f: a -> b \ ef): a -> b \ (ef - Http) + IO = x ->letclient = HttpLayer.newDefaultClient();run {f(x) } withhandlerHttp {defrequest(req, k) = k(HttpLayer.execute((_) -> Ok(), client, req)) }////// Runs the `Http` effect of the given function `f`.////// Re-interprets the `Http` effect using the `IO` effect.///@DefaultHandlerpubdefrunWithIO(f: Unit -> a \ ef): a \ (ef - Http) + IO = handle(f)()////// Middleware that intercepts the `Http` effect, enforcing a minimum interval/// between consecutive HTTP requests.///pubdefwithMinInterval(ims: {interval = Duration}, f: Unit -> a \ ef): a \ (ef - Http) + { Clock, Http } =letintervalMs = toMillis(ims#interval);regionrc {letlast = Ref.fresh(rc, 0i64);run { f() } withhandlerHttp {defrequest(req, k) =letnow = Clock.now();letwait = intervalMs- (now-Ref.get(last));if (wait>0i64) { unsafeIO { HttpLayer.sleep(wait) } };Ref.put(Clock.now(), last);k(Http.request(req)) } }////// Middleware that intercepts the `Http` effect, enforcing a token-bucket/// rate limit. Allows an initial burst of `burstSize` requests, then/// sustains at 1 request per `interval`.///pubdefwithTokenBucket(bs: {burstSize = Int32}, ims: {interval = Duration}, f: Unit -> a \ ef): a \ (ef - Http) + { Clock, Http } =letburstSize = bs#burstSize;letintervalMs = toMillis(ims#interval);regionrc {lettokens = Ref.fresh(rc, burstSize);letlastTime = Ref.fresh(rc, Clock.now());run { f() } withhandlerHttp {defrequest(req, k) =defacquire() = {letnow = Clock.now();letelapsed = now-Ref.get(lastTime);letadded = Int64.clampToInt32(min = 0, max = burstSize, elapsed/intervalMs);if (added>0) {Ref.put(Int32.min(burstSize, Ref.get(tokens) +added), tokens);Ref.put(Ref.get(lastTime) +Int32.toInt64(added) *intervalMs, lastTime) };if (Ref.get(tokens) >=1) {Ref.put(Ref.get(tokens) -1, tokens) } else {letwait = intervalMs- (Clock.now()-Ref.get(lastTime));if (wait>0i64) { unsafeIO { HttpLayer.sleep(wait) } };acquire() } };acquire();k(Http.request(req)) } }////// Middleware that intercepts the `Http` effect, enforcing a sliding-window/// rate limit. Allows at most `maxRequests` within any rolling `window`./// When the limit is reached, sleeps until the oldest request expires.///pubdefwithSlidingWindow(mr: {maxRequests = Int32}, wms: {window = Duration}, f: Unit -> a \ ef): a \ (ef - Http) + { Clock, Http } =letmaxRequests = mr#maxRequests;letwindowMs = toMillis(wms#window);regionrc {lettimestamps = MutDeque.empty(rc);run { f() } withhandlerHttp {defrequest(req, k) =defacquire() = {letnow = Clock.now();defevict() = matchMutDeque.peekFront(timestamps) {case Some(ts) ifnow-ts>windowMs =>discardMutDeque.popFront(timestamps);evict()case_ => () };evict();if (MutDeque.size(timestamps) <maxRequests) {MutDeque.pushBack(now, timestamps) } else {matchMutDeque.peekFront(timestamps) {case Some(oldest) =>letwait = windowMs- (now-oldest);if (wait>0i64) { unsafeIO { HttpLayer.sleep(wait) } };acquire()case None =>// maxRequests <= 0: sleep for the full window and retry.unsafeIO { HttpLayer.sleep(windowMs) };acquire() } } };acquire();k(Http.request(req)) } }////// Middleware that intercepts the `Http` effect, implementing a circuit breaker.////// Tracks consecutive failures (transport errors and 5xx server errors)./// After `failureThreshold` consecutive failures, the circuit opens and/// immediately rejects requests for `cooldown`./// After the cooldown, one probe request is allowed through (half-open state)./// A successful probe closes the circuit; a failed probe restarts the cooldown.////// Non-failure responses (2xx, 3xx, 4xx) reset the failure counter.///pubdefwithCircuitBreaker(ft: {failureThreshold = Int32},cdm: {cooldown = Duration},f: Unit -> a \ ef ): a \ (ef - Http) + { Clock, Http } =letfailureThreshold = ft#failureThreshold;letcooldownMs = toMillis(cdm#cooldown);regionrc {letfailures = Ref.fresh(rc, 0);letopenSince = Ref.fresh(rc, 0i64);run { f() } withhandlerHttp {defrequest(req, k) =letnow = Clock.now();letsince = Ref.get(openSince);if (since>0i64andnow-since<cooldownMs)// Open: reject immediatelyk(Err(IoError(ErrorKind.Other, "Circuit breaker is open")))else {// Closed or Half-Open: allow the requestletresult = Http.request(req);matchresult {case Err(_) =>letcount = Ref.get(failures) +1;Ref.put(count, failures);if (count>=failureThreshold) {Ref.put(Clock.now(), openSince) };k(result)case Ok(resp) =>if (HttpResponse.isServerError(resp)) {letcount = Ref.get(failures) +1;Ref.put(count, failures);if (count>=failureThreshold) {Ref.put(Clock.now(), openSince) } } else {Ref.put(0, failures);Ref.put(0i64, openSince) };k(result) } } } }////// Middleware that intercepts the `Http` effect, adding retry logic.////// The `strategy` function decides whether to retry and how long to wait./// Each `Http.request` is re-raised to an outer `Http` handler that performs the actual I/O.///pubdefwithRetry(strategy: (Int32, HttpRequest, RetryOutcome) -> RetryDecision \ ef1,f: Unit -> a \ ef2 ): a \ ef1 + (ef2 - Http) + Http =run { f() } withhandlerHttp {defrequest(req, k) =defloop(attempt) =matchHttp.request(req) {case Ok(resp) ifHttpResponse.isSuccess(resp) => Ok(resp)case Ok(resp) =>matchstrategy(attempt, req, RetryOutcome.HttpError(resp)) {case RetryDecision.Retry(d) => unsafeIO { HttpLayer.sleep(toMillis(d)); loop(attempt+1) } // sleep is pure herecase RetryDecision.Stop => Ok(resp) }case Err(error) =>matchstrategy(attempt, req, RetryOutcome.TransportError(error)) {case RetryDecision.Retry(d) => unsafeIO { HttpLayer.sleep(toMillis(d)); loop(attempt+1) } // sleep is pure herecase RetryDecision.Stop => Err(error) } };k(loop(0)) }////// Middleware that intercepts the `Http` effect, injecting default headers/// into every request. Headers already present on the request are not overridden.///pubdefwithDefaultHeaders(defaults: Map[String, List[String]],f: Unit -> a \ ef ): a \ (ef - Http) + Http =run { f() } withhandlerHttp {defrequest(req, k) =let HttpRequest(x) = req;letmerged = Map.unionWith((reqVals, _) -> reqVals, x#headers, defaults);k(Http.request(HttpRequest({headers = merged | x}))) }////// Middleware that intercepts the `Http` effect, prepending `base` to/// request URLs that do not already start with a scheme. Absolute URLs/// (where "://" appears before the first "/") are left unchanged.///pubdefwithBaseUrl(base: String, f: Unit -> a \ ef): a \ (ef - Http) + Http =letbase1 = if (String.endsWith(suffix = "/", base))String.dropRight(1, base)elsebase;run { f() } withhandlerHttp {defrequest(req, k) =let HttpRequest(x) = req;leturl = x#url;// Check that "://" appears before the first "/" to detect a scheme.lethasScheme = matchString.indexOf(substr = "://", url) {case Some(i) => matchString.indexOf(substr = "/", url) {case Some(s) => i<scase None => true }case None => false };if (hasScheme)k(Http.request(req))elseletpath1 = if (String.startsWith(prefix = "/", url))urlelse"/${url}";k(Http.request(HttpRequest({url = "${base1}${path1}" | x}))) }////// Middleware that intercepts the `Http` effect, logging each request/// and its corresponding response via the `Logger` effect at `Debug` level.///pubdefwithLogging(f: Unit -> a \ ef): a \ (ef - Http) + { Http, Logger } =run { f() } withhandlerHttp {defrequest(req, k) =letmethod = String.toUpperCase("${HttpRequest.method(req)}");leturl = HttpRequest.url(req);Logger.log( Severity.Debug,gray("-->") +text(" ") +bold(method) +text(" ") +cyan(url) );letresult = Http.request(req);matchresult {case Ok(resp) =>letstatus = HttpResponse.status(resp);letcolor = if (status>=200andstatus<=299)green("${status}")elseif (status>=400)red("${status}")elseyellow("${status}");letsize = Vector.length(HttpResponse.bodyBytes(resp));Logger.log( Severity.Debug,gray("<--") +text(" ") +color+text(" ") +cyan(url)+gray(" (${size} bytes)") )case Err(err) =>Logger.log( Severity.Debug,gray("<--") +text(" ") +red("ERR") +text(" ") +cyan(url)+text(" ")+red("${err}") ) };k(result) }}