///|
/// A request deadline (← go-zero's `timeout` middleware's `context.WithTimeout`):
/// a budget in milliseconds measured from a start instant on the shared clock.
/// A `budget_ms <= 0` means "no deadline" and never expires — go-zero's
/// convention for a disabled timeout.
pub struct Deadline {
budget_ms : Int64
started_ms : Int64
}
///|
/// Start a deadline of `budget_ms` milliseconds at time `now`.
pub fn Deadline::start(budget_ms : Int64, now : Int64) -> Deadline {
{ budget_ms, started_ms: now }
}
///|
/// Whether the deadline has passed at time `now`. A non-positive budget never
/// expires.
pub fn Deadline::expired(self : Deadline, now : Int64) -> Bool {
self.budget_ms > 0L && now - self.started_ms >= self.budget_ms
}
///|
/// Milliseconds left before the deadline at time `now` (never negative); `-1`
/// for a disabled (non-positive-budget) deadline, which has no finite remaining.
pub fn Deadline::remaining(self : Deadline, now : Int64) -> Int64 {
if self.budget_ms <= 0L {
-1L
} else {
let left = self.budget_ms - (now - self.started_ms)
if left < 0L {
0L
} else {
left
}
}
}
///|
/// The event stream a timed-out request receives: a `503 Service Unavailable`
/// with go-zero's plain-text timeout body. (go-zero's timeout middleware answers
/// `503`.) A pure value so the response is testable without the async transport.
fn timeout_events() -> Array[@moonasgi.Event] {
[
@moonasgi.Event::HttpResponseStart(
status=503,
headers=[("content-type", "text/plain; charset=utf-8")],
trailers=false,
),
@moonasgi.Event::HttpResponseBody(body=b"service timeout", more_body=false),
]
}
///|
/// Timeout middleware (← go-zero's `TimeoutHandler`): establish a per-request
/// `Deadline` of `budget_ms` at `clock.now()` for the wrapped app.
///
/// **Async boundary (faithful model).** *Preemptively* aborting an in-flight
/// handler the instant its deadline fires requires racing the handler against a
/// timer and cancelling the loser — in MoonBit that is `@async.any([handler,
/// timer])` with structured cancellation, which only runs under the native async
/// runtime and cannot be driven synchronously. What this middleware does
/// portably: it installs the deadline and enforces it on the response path — if
/// the handler blows its budget before emitting its first event, the client
/// receives a `503` timeout (from `timeout_events`) and the late response is
/// suppressed. The remaining gap (a handler that hangs and never emits) is closed
/// by the race/cancel wired at the async server edge. A `budget_ms <= 0` disables
/// the timeout, passing straight through.
pub fn timeout(budget_ms : Int64, clock : Clock) -> Middleware {
inner => {
(scope, receive, send) => {
match scope {
Http(_) => {
let deadline = Deadline::start(budget_ms, clock.now())
let done : Ref[Bool] = { val: false }
let guarded : @moonasgi.Send = event => {
if done.val {
// deadline already answered — drop the handler's late output
()
} else if deadline.expired(clock.now()) {
done.val = true
for e in timeout_events() {
send(e)
}
} else {
send(event)
}
}
inner(scope, receive, guarded)
}
_ => inner(scope, receive, send)
}
}
}
}
///|
/// Parse a non-negative decimal string to `Int`, returning `None` for an empty
/// string or any non-digit character. Used to read `Content-Length`; core has no
/// portable integer parser, so it is hand-written.
fn parse_uint(s : String) -> Int? {
if s.length() == 0 {
return None
}
let mut acc = 0
for i in 0.. '9'.to_int() {
return None
}
acc = acc * 10 + (c - '0'.to_int())
}
Some(acc)
}
///|
/// Whether an HTTP scope's declared `Content-Length` exceeds `limit` bytes. A
/// missing or unparseable length is treated as *not* exceeding (go-zero's
/// `MaxBytesHandler` likewise enforces on the declared/streamed size). The header
/// name is matched case-insensitively.
fn content_length_exceeds(scope : @moonasgi.Scope, limit : Int) -> Bool {
match scope {
Http(hs) => {
for pair in hs.headers {
if pair.0.to_lower() == "content-length" {
match parse_uint(pair.1) {
Some(n) => return n > limit
None => return false
}
}
}
false
}
_ => false
}
}
///|
/// The event stream an over-limit request receives: a `413 Payload Too Large`
/// with a short plain-text body. A pure value, testable without the transport.
fn payload_too_large_events() -> Array[@moonasgi.Event] {
[
@moonasgi.Event::HttpResponseStart(
status=413,
headers=[("content-type", "text/plain; charset=utf-8")],
trailers=false,
),
@moonasgi.Event::HttpResponseBody(
body=b"413 Payload Too Large",
more_body=false,
),
]
}
///|
/// Max-bytes middleware (← go-zero's `MaxBytesHandler`): reject any HTTP request
/// whose declared `Content-Length` exceeds `limit` bytes with `413 Payload Too
/// Large`, before the wrapped app runs. A `limit <= 0` disables the check. Non-
/// HTTP scopes pass through untouched.
pub fn maxbytes(limit : Int) -> Middleware {
inner => {
(scope, receive, send) => {
if limit > 0 && content_length_exceeds(scope, limit) {
for event in payload_too_large_events() {
send(event)
}
} else {
inner(scope, receive, send)
}
}
}
}