| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
Ecluse.Server.Response
Description
The serve-outcome model, the per-outcome status mapping, and the agnostic shape of an error body.
Every client-facing reply is the rendering of one serve outcome — admit the
request, or reject it — so that an error maps to the status a client can act on
rather than a generic 403/500. The model and the per-outcome status mapping live
here; the WAI layer that turns an ArtifactStatus into an actual response and
streams the body is separate (see docs/architecture/web-layer.md).
This module decides the HTTP status of a refusal but holds __no body shape of
its own__: the bytes a client reads an error from are an ecosystem's (npm's
{"error": …} JSON, a different surface for PyPI), so a mount supplies a
MountRenderer — chosen at the composition root alongside its path grammar — and
the agnostic web layer never names one. appendHelp is the ecosystem-neutral part
the renderer reuses: joining the operator help message onto a denial.
The outcome model
A ServeDecision is Admit or Reject with a Rejection carrying a
RejectReason. A rejection is either by policy (a rule denied the version,
including deny-by-default) or unavailable — the version could not be decided,
carrying its Transience: whether the evaluator believes the condition will
self-heal. The whole verdict pipeline (Ecluse.Rules) feeds this: a rules
Decision projects to a ServeDecision via serveDecisionOf.
Status follows the cause
For a concrete artifact (one specific version) the outcome renders to a
single ArtifactStatus. The load-bearing rule is __503 only when we believe it
will resolve__ — a transient upstream/advisory condition invites a retry, while a
permanent or internal inability to decide (WontResolve) is a 500, because
retrying it cannot help and we should not invite it. A policy rejection is a
403 whose body the mount's MountRenderer shapes. A packument request has
no single status — its versions are filtered and the status is chosen over the
surviving set — so this module deliberately maps per outcome, not per request.
An operator help message, when configured, is appended to every denial
(appendHelp) so clients are told where to ask; how the joined text is then
wrapped into bytes is the mount renderer's.
Synopsis
- data ServeDecision
- data Rejection = Rejection {}
- data RejectReason
- data Transience
- newtype RetryAfter = RetryAfter Int
- newtype RuleName = RuleName Text
- serveDecisionOf :: PackageDetails -> Decision -> ServeDecision
- data ArtifactStatus
- artifactStatus :: ServeDecision -> ArtifactStatus
- artifactStatusCode :: ArtifactStatus -> Int
- data PackumentStatus
- packumentStatus :: [ServeDecision] -> PackumentStatus
- packumentStatusCode :: PackumentStatus -> Int
- longestRetry :: [Maybe RetryAfter] -> Maybe RetryAfter
- data HelpMessage
- mkHelpMessage :: Text -> HelpMessage
- unHelpMessage :: HelpMessage -> Text
- appendHelp :: Maybe HelpMessage -> Text -> Text
- data RenderedBody = RenderedBody {}
- newtype MountRenderer = MountRenderer {
- renderError :: Maybe HelpMessage -> Text -> RenderedBody
Serve outcomes
data ServeDecision Source #
The outcome of deciding a request: serve it, or refuse it with a reason.
Every client-facing reply renders one of these. Admit carries no payload — the
artifact or packument is what is then streamed; Reject carries the Rejection
that explains the refusal and selects the status.
Constructors
| Admit | Serve the request (the |
| Reject Rejection | Refuse the request, with the reason and a client-facing message. |
Instances
| Show ServeDecision Source # | |
Defined in Ecluse.Server.Response Methods showsPrec :: Int -> ServeDecision -> ShowS # show :: ServeDecision -> String # showList :: [ServeDecision] -> ShowS # | |
| Eq ServeDecision Source # | |
Defined in Ecluse.Server.Response Methods (==) :: ServeDecision -> ServeDecision -> Bool # (/=) :: ServeDecision -> ServeDecision -> Bool # | |
A refusal: why it was refused, and an intuitive message for the client.
The rejectionReason selects the HTTP status; the rejectionMessage is the
human-facing text rendered into the response body.
Constructors
| Rejection | |
Fields
| |
data RejectReason Source #
Why a request was refused.
A policy refusal is a deliberate verdict and is final for this request; an
unavailability is an inability to decide and carries whether it is expected to
self-heal, which is what separates a retryable 503 from a terminal 500/403.
Constructors
| ByPolicy RuleName | A rule denied the version (including deny-by-default). The |
| Unavailable Transience | The version could not be decided — an effectful rule the evaluator
needed could not be consulted (advisory source down, timeout). This is
fail-closed: a never-vetted version is not admitted just because the
scanner is unreachable. The |
| MissingIntegrity | The version's selected artifact carries __no integrity digest of any
kind__ (neither an SRI nor a legacy shasum), so its bytes cannot be tied to
a tamper-evident fingerprint. A version without an integrity check is
inadmissible from an untrusted (public) upstream — there is nothing to
detect a divergence against — so admission refuses it outright. This is a
deliberate, deny-by-default admission policy, not a rule decision and not
a retryable inability: it maps to a |
| BelowIntegrityFloor | The version's selected artifact carries an integrity digest, but its
strongest one is weaker than the configured minimum algorithm (e.g. a
legacy SHA-1 shasum only, under the default SHA-256 floor). A collision-broken
digest cannot tie the bytes to a tamper-evident fingerprint, so it is
inadmissible from an untrusted (public) upstream — distinct from
|
| UpstreamInvalid | A responding upstream returned an invalid response for the requested
package — its packument self-reported a name for a different package, so that
origin is untrusted for this request and its contribution is dropped. It is not
a policy verdict and not a retryable inability but a gateway fault: when no
origin yields a valid packument and a responding one was invalid this way, the
packument request maps to a |
Instances
| Show RejectReason Source # | |
Defined in Ecluse.Server.Response Methods showsPrec :: Int -> RejectReason -> ShowS # show :: RejectReason -> String # showList :: [RejectReason] -> ShowS # | |
| Eq RejectReason Source # | |
Defined in Ecluse.Server.Response | |
data Transience Source #
Whether an unavailability is expected to resolve on its own.
This is the single distinction the serve status mapping turns on: a transient cause
(WillResolve) is worth retrying (a 503); a permanent or internal one
(WontResolve) is not, so it must not be dressed up as a retryable 503 (it is a
500). The effectful tier sets it from the nature of the failure: an upstream
outage, rate limit, timeout, or open breaker is transient; an internal or parse
fault is not.
Constructors
| WillResolve (Maybe RetryAfter) | Transient — a retry may succeed (an advisory source briefly down, a
timeout, an open circuit breaker). The optional |
| WontResolve | Not expected to self-heal (an internal or parse error). Retrying cannot
help, so the request is a |
Instances
| Show Transience Source # | |
Defined in Ecluse.Rules.Types Methods showsPrec :: Int -> Transience -> ShowS # show :: Transience -> String # showList :: [Transience] -> ShowS # | |
| Eq Transience Source # | |
Defined in Ecluse.Rules.Types | |
newtype RetryAfter Source #
A Retry-After delay, in whole seconds. A 'newtype' so a raw count of seconds
is never confused with some other integer when it reaches the response header.
Constructors
| RetryAfter Int |
Instances
| Show RetryAfter Source # | |
Defined in Ecluse.Rules.Types Methods showsPrec :: Int -> RetryAfter -> ShowS # show :: RetryAfter -> String # showList :: [RetryAfter] -> ShowS # | |
| Eq RetryAfter Source # | |
Defined in Ecluse.Rules.Types | |
| Ord RetryAfter Source # | |
Defined in Ecluse.Rules.Types Methods compare :: RetryAfter -> RetryAfter -> Ordering # (<) :: RetryAfter -> RetryAfter -> Bool # (<=) :: RetryAfter -> RetryAfter -> Bool # (>) :: RetryAfter -> RetryAfter -> Bool # (>=) :: RetryAfter -> RetryAfter -> Bool # max :: RetryAfter -> RetryAfter -> RetryAfter # min :: RetryAfter -> RetryAfter -> RetryAfter # | |
The name of the rule that decided a refusal, carried for the audit trail and
the denial body. A 'newtype' over the ruleName text so a rule
identity is not just any string.
serveDecisionOf :: PackageDetails -> Decision -> ServeDecision Source #
Project a rules Decision (see Ecluse.Rules) into a serve outcome. Pure
and total.
An Approved decision admits; a Denied or DeniedByDefault decision rejects
ByPolicy, naming the deciding rule and carrying the human-readable
renderDecision as the message. An Undecidable decision (a needed effectful rule
could not be consulted) rejects as Unavailable, carrying its Transience so the
status mapping can choose 503 vs 500 — fail-closed, exactly as a denial
removes a version, but flagged retryable when the cause may self-heal. Only an
approval admits.
Concrete-artifact status
data ArtifactStatus Source #
The HTTP status a concrete-artifact request renders to. A domain sum
type (not a raw code) so the mapping is total and the WAI layer reads off an
exhaustive set; artifactStatusCode gives the numeric code.
A packument request has no single status — its versions are filtered and a status is chosen over the survivors — so this type models only the concrete-artifact case.
Constructors
| Ok |
|
| Forbidden |
|
| Unavailable' (Maybe RetryAfter) |
|
| ServerError |
|
| NotFound |
|
Instances
| Show ArtifactStatus Source # | |
Defined in Ecluse.Server.Response Methods showsPrec :: Int -> ArtifactStatus -> ShowS # show :: ArtifactStatus -> String # showList :: [ArtifactStatus] -> ShowS # | |
| Eq ArtifactStatus Source # | |
Defined in Ecluse.Server.Response Methods (==) :: ArtifactStatus -> ArtifactStatus -> Bool # (/=) :: ArtifactStatus -> ArtifactStatus -> Bool # | |
artifactStatus :: ServeDecision -> ArtifactStatus Source #
Map a serve outcome to its concrete-artifact status. Pure and total.
403 for a policy refusal; 503 when an unavailability WillResolve (a retry
may help); 500 when it WontResolve. __503 only when we believe it will
resolve__ — a permanent or internal inability is a 500, since retrying it
cannot help. A 404 upstream miss is not a serve decision (the version exists
unless upstream says otherwise), so it is not produced here.
artifactStatusCode :: ArtifactStatus -> Int Source #
The numeric HTTP status code for an ArtifactStatus. Pure and total.
Packument status (over the merged survivor set)
data PackumentStatus Source #
The HTTP status a packument request renders to, chosen once the merged
survivor set is known. A packument has no single per-version status — its versions
are filtered and merged across upstreams — so the status is chosen __over the
survivors__: with at least one survivor the document is served; with none, the
status follows the most recoverable cause among the exclusions (see
packumentStatus).
A domain sum (not a raw code) so the mapping is total and the WAI layer reads an
exhaustive set; packumentStatusCode gives the numeric code. There is no 404: a
packument whose versions were all withheld is not a miss — the package exists,
so a genuine upstream absence (no such package at all) is a separate concern of the
serve layer, decided before the merge.
Constructors
| PackumentOk |
|
| PackumentForbidden |
|
| PackumentUnavailable (Maybe RetryAfter) |
|
| PackumentBadGateway |
|
| PackumentServerError |
|
Instances
| Show PackumentStatus Source # | |
Defined in Ecluse.Server.Response Methods showsPrec :: Int -> PackumentStatus -> ShowS # show :: PackumentStatus -> String # showList :: [PackumentStatus] -> ShowS # | |
| Eq PackumentStatus Source # | |
Defined in Ecluse.Server.Response Methods (==) :: PackumentStatus -> PackumentStatus -> Bool # (/=) :: PackumentStatus -> PackumentStatus -> Bool # | |
packumentStatus :: [ServeDecision] -> PackumentStatus Source #
Choose a packument's status from the per-version serve outcomes weighed for it:
the Admits for surviving versions (trusted, or rule-approved) and the Rejects
for excluded ones — plus any Reject a needed-but-unavailable upstream contributes.
Pure and total.
Any Admit means the merged document has a survivor, so it is served
(PackumentOk). With no survivor the status follows the most recoverable cause
among the exclusions, so a retry is invited exactly when it might produce survivors:
- any
UnavailableWillResolve→503, suggesting the longestRetryAfterany such cause asked for (so every transient cause has likely cleared by then); - else any
UpstreamInvalid→502(a responding upstream returned a packument for a different package; ranked above the terminal500/403because it names a concrete, actionable gateway fault, but below the retryable503since a transient origin may yet come back with a valid document); - else any
UnavailableWontResolve→500(a permanent inability — a retry cannot help, so it is not dressed up as a retryable503); - else every exclusion is a deny-by-default cause — a
ByPolicyrule denial or an admission refusal (MissingIntegrityorBelowIntegrityFloor), __including the degenerate empty input__ →403: there is nothing to serve and nothing invites a retry.
Never 404: the versions existed and were withheld (see PackumentStatus).
packumentStatusCode :: PackumentStatus -> Int Source #
The numeric HTTP status code for a PackumentStatus. Pure and total.
longestRetry :: [Maybe RetryAfter] -> Maybe RetryAfter Source #
The longest suggested RetryAfter among transient causes, or Nothing when
none of them suggested a delay.
Denial rendering
data HelpMessage Source #
An operator-configured message appended to every denial — typically where to ask for help (e.g. a support channel). Stored trimmed of surrounding whitespace so it joins the denial text with a single separating space and an all-blank value contributes nothing.
Instances
| Show HelpMessage Source # | |
Defined in Ecluse.Server.Response Methods showsPrec :: Int -> HelpMessage -> ShowS # show :: HelpMessage -> String # showList :: [HelpMessage] -> ShowS # | |
| Eq HelpMessage Source # | |
Defined in Ecluse.Server.Response | |
mkHelpMessage :: Text -> HelpMessage Source #
Build a HelpMessage, trimming surrounding whitespace.
unHelpMessage :: HelpMessage -> Text Source #
The trimmed help-message text.
appendHelp :: Maybe HelpMessage -> Text -> Text Source #
Append a non-blank operator HelpMessage to a denial message, separated by a
single space; a blank or absent help message contributes nothing.
This is the ecosystem-neutral part of denial rendering — every ecosystem appends
the operator's help text the same way. How the joined text is then wrapped into
body bytes is the mount's MountRenderer.
data RenderedBody Source #
A rendered error body: its Content-Type and the bytes.
The agnostic serve layer chooses the HTTP status; the body shape — JSON, plain
text, HTML — is the mount's, so a MountRenderer returns this pair and the WAI
layer reads the content type off it rather than assuming one.
Constructors
| RenderedBody | |
Fields
| |
Instances
| Show RenderedBody Source # | |
Defined in Ecluse.Server.Response Methods showsPrec :: Int -> RenderedBody -> ShowS # show :: RenderedBody -> String # showList :: [RenderedBody] -> ShowS # | |
| Eq RenderedBody Source # | |
Defined in Ecluse.Server.Response | |
newtype MountRenderer Source #
A mount's ecosystem-specific error renderer — the Handle that keeps the npm
{"error": …} shape (and any other ecosystem's) out of the agnostic web layer.
The status machinery here is ecosystem-agnostic, but the body a client reads an
error from is not: an npm client expects a JSON {"error": …} object, a PyPI
client a different surface. Each mount supplies a renderer, chosen at the
composition root alongside its path grammar, so the web layer holds no body shape
of its own. renderError shapes a denial or meta-route error (a 403/404/501
body) from the optional operator help message and the human-facing reason.
Constructors
| MountRenderer | |
Fields
| |