| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
Ecluse.Core.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.Core.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.Core.Server.Response Methods showsPrec :: Int -> ServeDecision -> ShowS # show :: ServeDecision -> String # showList :: [ServeDecision] -> ShowS # | |
| Eq ServeDecision Source # | |
Defined in Ecluse.Core.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.Core.Server.Response Methods showsPrec :: Int -> RejectReason -> ShowS # show :: RejectReason -> String # showList :: [RejectReason] -> ShowS # | |
| Eq RejectReason Source # | |
Defined in Ecluse.Core.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 resilience harness 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.Core.Rules.Types Methods showsPrec :: Int -> Transience -> ShowS # show :: Transience -> String # showList :: [Transience] -> ShowS # | |
| Eq Transience Source # | |
Defined in Ecluse.Core.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.Core.Rules.Types Methods showsPrec :: Int -> RetryAfter -> ShowS # show :: RetryAfter -> String # showList :: [RetryAfter] -> ShowS # | |
| Eq RetryAfter Source # | |
Defined in Ecluse.Core.Rules.Types | |
| Ord RetryAfter Source # | |
Defined in Ecluse.Core.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 carries a distinct type rather than a bare Text.
serveDecisionOf :: PackageDetails -> Decision -> ServeDecision Source #
Project a rules Decision (see Ecluse.Core.Rules) into a serve outcome. Pure
and total.
An Admitted decision admits; a Blocked or BlockedByDefault decision rejects
ByPolicy, naming the deciding rule and carrying the human-readable
renderDecision as the message. An Undecidable decision (a fail-closed rule that
could not be computed) 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
admission 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.Core.Server.Response Methods showsPrec :: Int -> ArtifactStatus -> ShowS # show :: ArtifactStatus -> String # showList :: [ArtifactStatus] -> ShowS # | |
| Eq ArtifactStatus Source # | |
Defined in Ecluse.Core.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.Core.Server.Response Methods showsPrec :: Int -> PackumentStatus -> ShowS # show :: PackumentStatus -> String # showList :: [PackumentStatus] -> ShowS # | |
| Eq PackumentStatus Source # | |
Defined in Ecluse.Core.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.Core.Server.Response Methods showsPrec :: Int -> HelpMessage -> ShowS # show :: HelpMessage -> String # showList :: [HelpMessage] -> ShowS # | |
| Eq HelpMessage Source # | |
Defined in Ecluse.Core.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.Core.Server.Response Methods showsPrec :: Int -> RenderedBody -> ShowS # show :: RenderedBody -> String # showList :: [RenderedBody] -> ShowS # | |
| Eq RenderedBody Source # | |
Defined in Ecluse.Core.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
| |