A normalized API error.
Both transports collapse into this one shape. A REST call that returns a 4xx or
5xx becomes a GhEx.Error carrying the status and GitHub's error body. A GraphQL
call that returns a 200-with-errors body normalizes into the same struct via
from_graphql/2.
It is also an exception, so streaming helpers that cannot return an :error
tuple can raise it.
classify/1 and retryable?/1 sort an already-returned error (a
GhEx.Error or the raw transport exception GhEx.REST.result/0 and
GhEx.GraphQL.result/0 can also carry) into a coarse taxonomy, for a
caller such as a job queue deciding whether to retry, back off, or discard.
Summary
Functions
Classifies an error into a classification/0.
Builds an error from a GraphQL 200-with-errors response.
Builds an error from a GraphQL HTTP 200 whose body is not a JSON object.
Builds an error from a failed REST response.
Whether an error is worth retrying, consistent with
GhEx.RateLimit.retry/2.
Types
@type classification() ::
:rate_limited | :permission | :not_found | :validation | :server | :transport
The coarse classification classify/1 sorts an error into.
:rate_limited- a429, or a403thatGhEx.RateLimitrecognizes as a primary or secondary rate limit. Worth waiting out and retrying.:permission- a401or a plain403(not a rate limit): the token lacks the scope or access. Retrying without a credential change cannot succeed.:not_found- a404or a410(gone): the resource is not there, permanently as far as the caller is concerned.:validation- a4xxdescribing a problem with the request itself (422failed semantic validation,400bad request, and any other unrecognized4xx), or a GraphQL 200-with-errorsresponse (no HTTP status to classify by). Retrying the same request cannot succeed; the caller must change it.:server- a408, or a5xx: GitHub's side, generally transient.:transport- not aGhEx.Errorat all, but the rawReq/Mintexception returned for a connection failure.
Functions
@spec classify(t() | Exception.t()) :: classification()
Classifies an error into a classification/0.
Accepts a %GhEx.Error{} or the raw transport exception a REST or GraphQL
call's error arm can carry instead (GhEx.REST.result/0,
GhEx.GraphQL.result/0). The 403 disambiguation (rate limit vs plain
permission denial) reuses GhEx.RateLimit.rate_limited?/3, the same check
GhEx.RateLimit.retry/2 runs against a live response, so the two never
disagree.
Examples
iex> GhEx.Error.classify(%GhEx.Error{status: 429})
:rate_limited
iex> GhEx.Error.classify(%GhEx.Error{
...> status: 403,
...> headers: %{"x-ratelimit-remaining" => ["0"], "x-ratelimit-reset" => ["9999999999"]}
...> })
:rate_limited
iex> GhEx.Error.classify(%GhEx.Error{status: 403})
:permission
iex> GhEx.Error.classify(%GhEx.Error{status: 404})
:not_found
iex> GhEx.Error.classify(%GhEx.Error{status: 410})
:not_found
iex> GhEx.Error.classify(%GhEx.Error{status: 422})
:validation
iex> GhEx.Error.classify(%GhEx.Error{status: nil})
:validation
iex> GhEx.Error.classify(%GhEx.Error{status: 503})
:server
iex> GhEx.Error.classify(%RuntimeError{})
:transport
Builds an error from a GraphQL 200-with-errors response.
GraphQL returns HTTP 200 even on failure, with the failures in an errors
array and any partial result in data. Both are preserved: :errors holds the
array, :message is the first error's message, and :body carries the whole
%{"data" => ..., "errors" => ...} envelope so partial data stays reachable.
Builds an error from a GraphQL HTTP 200 whose body is not a JSON object.
GitHub's GraphQL endpoint answers 200 with a JSON object envelope; a list,
scalar, or nil body cannot carry data/errors and is unusable. :status
is left nil (the HTTP 200 is not the error) and the raw body is preserved on
:body for diagnostics.
@spec from_response(Req.Response.t()) :: t()
Builds an error from a failed REST response.
Populates :status, :message, :body, :errors, and :documentation_url
from the response status and JSON body. :errors is set when the body carries
a top-level "errors" array. :headers keeps the response's headers (the
same %{binary => [binary]} shape Req.Response.headers uses), so
classify/1 and retryable?/1 can disambiguate a 403 after the fact,
without a live response to re-inspect.
@spec retryable?(t() | Exception.t()) :: boolean()
Whether an error is worth retrying, consistent with
GhEx.RateLimit.retry/2.
True for :rate_limited and for the same transient-server statuses
retry/2 retries (408, 500, 502, 503, 504). False for
:permission, :not_found, :validation, any other status, and for a raw
transport exception: retry/2 does not retry those either (a connection
failure is left to the caller or the underlying transport, not this
policy), so retryable?/1 reports the same false here rather than
guessing at a different answer than the retry hook actually in effect.
Examples
iex> GhEx.Error.retryable?(%GhEx.Error{status: 429})
true
iex> GhEx.Error.retryable?(%GhEx.Error{status: 503})
true
iex> GhEx.Error.retryable?(%GhEx.Error{status: 403})
false
iex> GhEx.Error.retryable?(%GhEx.Error{status: 404})
false
iex> GhEx.Error.retryable?(%RuntimeError{})
false