# Error Handling

When a launch fails, you need to know what went wrong and how to
respond. Ltix returns `{:error, exception}` from both `handle_login/3`
and `handle_callback/3`, with errors organized into three classes using
[Splode](https://splode.hexdocs.pm). You can match on the category
without knowing every individual error type.

## Error classes

| Class | Module | Meaning |
|---|---|---|
| `:invalid` | `Ltix.Errors.Invalid` | Bad input (malformed JWT, missing claims, unknown registration) |
| `:security` | `Ltix.Errors.Security` | Security violation (bad signature, expired token, nonce replay) |
| `:unknown` | `Ltix.Errors.Unknown` | Unexpected failure (network errors, bugs) |

## HTTP status codes

Every error has an HTTP status code derived from its class.

| Class | Status |
|---|---|
| `:invalid` | 400 |
| `:security` | 401 |
| `:unknown` | 500 |

Use `Ltix.Errors.status_code/1` to get it.

```elixir
case Ltix.handle_callback(params, state) do
  {:ok, context} ->
    handle_launch(conn, context)

  {:error, error} ->
    conn
    |> put_status(Ltix.Errors.status_code(error))
    |> text(Exception.message(error))
end
```

### Plug.Exception

If your application depends on Plug, Ltix errors automatically implement
the `Plug.Exception` protocol. Phoenix error views and `Plug.Debugger`
use the correct status code without any extra work on your part.

## Matching on class

Every Ltix error struct has a `class` field you can match on for
different handling per category.

```elixir
case Ltix.handle_callback(params, state) do
  {:ok, context} ->
    handle_launch(conn, context)

  {:error, error} ->
    status = Ltix.Errors.status_code(error)

    case error.class do
      :invalid ->
        conn |> put_status(status) |> text("Bad request: #{Exception.message(error)}")

      :security ->
        conn |> put_status(status) |> text("Unauthorized: #{Exception.message(error)}")

      :unknown ->
        Logger.error("LTI launch failed: #{Exception.message(error)}")
        conn |> put_status(status) |> text("Internal error")
    end
end
```

## Matching on specific errors

For finer control, match on the error struct directly.

```elixir
case Ltix.handle_login(params, launch_url) do
  {:ok, result} ->
    # ...

  {:error, %Ltix.Errors.Invalid.RegistrationNotFound{issuer: issuer}} ->
    Logger.warning("Unknown platform attempted login: #{issuer}")
    conn |> put_status(404) |> text("Platform not registered")

  {:error, %Ltix.Errors.Invalid.MissingParameter{parameter: param}} ->
    conn |> put_status(400) |> text("Missing required parameter: #{param}")

  {:error, error} ->
    conn |> put_status(400) |> text(Exception.message(error))
end
```

## Single and grouped errors

A single failure returns its error struct directly. When validation finds several problems at once, such as two malformed claims, they are grouped in their class struct and `errors` lists each one.

```elixir
case Ltix.LaunchClaims.from_json(claims) do
  {:ok, claims} ->
    claims

  {:error, %Ltix.Errors.Invalid{errors: errors}} ->
    Enum.each(errors, &Logger.warning(Exception.message(&1)))

  {:error, error} ->
    Logger.warning(Exception.message(error))
end
```

The message of a grouped error lists the message of each error it contains.

## Invalid options

Ltix raises `Ltix.Errors.Invalid.InvalidOption` when a function gets an unknown option, a missing required option, or an option with a value of the wrong type.

```elixir
Ltix.JWK.generate(key_sise: 4096)
** (Ltix.Errors.Invalid.InvalidOption) Option :key_sise is unknown
```

## Error messages and spec references

Every error produces a human-readable message via `Exception.message/1`
and most have a `spec_ref` field with the violated spec section.

```elixir
error = %Ltix.Errors.Security.TokenExpired{spec_ref: "Sec §5.1.3 step 7"}

Exception.message(error)
#=> "JWT token has expired [Sec §5.1.3 step 7]"

error.spec_ref
#=> "Sec §5.1.3 step 7"
```

The spec reference is useful for debugging because it identifies exactly
which validation step failed.

## Invalid errors

These indicate problems with the incoming data or the calling code.

| Error | Fields | When |
|---|---|---|
| `MissingParameter` | `parameter`, `spec_ref` | OIDC login request missing a required param |
| `MissingClaim` | `claim`, `spec_ref` | A JWT or platform response, such as a memberships page, is missing a required claim, such as `sub` or `resource_link.id` |
| `InvalidClaim` | `claim`, `value`, `message`, `spec_ref` | A claim in a JWT or platform response has an invalid value, or a custom claim parser returned an error |
| `InvalidField` | `struct`, `field`, `value`, `reason`, `spec_ref` | A constructor such as `Ltix.Registration.new/1`, `Ltix.GradeService.Score.new/1`, or `Ltix.DeepLinking.ContentItem.Link.new/1` got an unknown, missing, or invalid field, or a line item to create or update has an invalid field |
| `InvalidOption` | `key`, `value`, `reason` | A function got an unknown, missing, or invalid option |
| `InvalidJSON` | `spec_ref` | The ID token is not a JWT whose header and payload are JSON objects |
| `RegistrationNotFound` | `issuer`, `client_id` | No registration matches the issuer |
| `DeploymentNotFound` | `deployment_id` | No deployment matches the JWT's deployment_id |

## Security errors

These indicate the launch failed a security check.

| Error | Fields | When |
|---|---|---|
| `SignatureInvalid` | `spec_ref` | JWT signature doesn't verify against the platform's public key |
| `TokenExpired` | `spec_ref` | JWT `exp` claim is in the past, or its `iat` claim is older than `:max_token_age` |
| `TokenNotYetValid` | `claim`, `spec_ref` | JWT `iat` or `nbf` claim is in the future |
| `IssuerMismatch` | `expected`, `actual`, `spec_ref` | JWT `iss` doesn't match the registration |
| `AudienceMismatch` | `claim`, `expected`, `actual`, `spec_ref` | JWT `aud` is not the registration's client_id or a list containing only it, or `azp` is not the client_id |
| `AlgorithmNotAllowed` | `algorithm`, `spec_ref` | JWT uses something other than RS256 |
| `StateMismatch` | `spec_ref` | OIDC state from callback doesn't match the session |
| `NonceMissing` | `spec_ref` | JWT has no `nonce` claim |
| `NonceNotFound` | `spec_ref` | Nonce wasn't issued by this tool |
| `NonceReused` | `spec_ref` | Nonce was already consumed (replay attempt) |
| `KidMissing` | `spec_ref` | JWT header has no `kid` field |
| `KidNotFound` | `kid`, `spec_ref` | `kid` not found in the platform's JWKS |
| `LocalNetworkAccessDenied` | `url`, `address_space` | The platform's key set is on a local or loopback address that `:local_network_access` doesn't allow |
| `AuthenticationFailed` | `error`, `error_description`, `error_uri`, `spec_ref` | Platform returned an error response |

## Unknown errors

These indicate an unexpected failure outside the incoming request.

| Error | Fields | When |
|---|---|---|
| `StorageError` | `callback`, `result`, `reason` | A storage adapter callback returned `{:error, reason}`, an unexpected value, or a deployment for a different `deployment_id` |
| `TransportError` | `status`, `body`, `headers`, `url`, `spec_ref` | An HTTP request to the platform failed |
| `RedirectNotAllowed` | `status`, `url`, `location`, `spec_ref` | A platform endpoint returned an unsafe redirect |
| `KeySetNotStarted` | `key_set` | Raised when a launch needs a key set that is not started in the supervision tree |
| `Unknown` | `error` | Any other unexpected failure |

## Deep linking errors

These errors come from `Ltix.DeepLinking.build_response/3`. Content item constructors such as `Ltix.DeepLinking.ContentItem.Link.new/1` return `InvalidField`. Errors about one item in a response include its `index` in the list you passed.

| Error | Fields | When |
|---|---|---|
| `InvalidContentItem` | `index`, `value`, `reason`, `spec_ref` | A response item is not a content item or has no valid type, or the items are not a list |
| `InvalidMessageType` | `message_type`, `spec_ref` | The launch is not a deep linking request |
| `ContentItemTypeNotAccepted` | `index`, `type`, `accept_types`, `spec_ref` | An item's type is not in `accept_types` |
| `ContentItemsExceedLimit` | `count`, `spec_ref` | Several items when `accept_multiple` is `false` |
| `LineItemNotAccepted` | `index`, `spec_ref` | A resource link has a line item when `accept_line_item` is `false` |
| `PresentationTargetNotAccepted` | `index`, `target`, `accept_presentation_document_targets`, `spec_ref` | An item's `iframe`, `window`, or `embed` is not in `accept_presentation_document_targets` |
| `MediaTypeNotAccepted` | `index`, `media_type`, `accept_media_types`, `spec_ref` | A file's media type does not match `accept_media_types` |

## Advantage service errors

These errors apply to LTI Advantage service calls (memberships, AGS,
etc.) and OAuth authentication.

### Invalid

| Error | Fields | When |
|---|---|---|
| `ServiceNotAvailable` | `service`, `reason`, `spec_ref` | The launch has no claim for the service, an endpoint or URL the operation needs is missing, the endpoint does not support the service version, or the registration has no token endpoint |
| `TokenRequestFailed` | `error`, `error_description`, `status`, `body`, `spec_ref` | OAuth token request failed |
| `MalformedResponse` | `service`, `reason`, `spec_ref` | Platform returned an unparseable response |
| `CollectionTooLarge` | `service`, `option`, `count`, `max`, `spec_ref` | An eager fetch such as `list_results/2` exceeded its limit option, such as `max_results` |
| `ScopeMismatch` | `scopes`, `granted_scopes`, `spec_ref` | Client lacks every scope in `scopes` that would authorize the request |
| `InvalidEndpoint` | `service`, `spec_ref` | Wrong endpoint struct passed to a service |
| `MissingLineItemId` | `line_item`, `spec_ref` | A grade operation received a line item without an `id` |

### Security

| Error | Fields | When |
|---|---|---|
| `AccessDenied` | `status`, `body`, `url`, `spec_ref` | Platform responded to a service request or key set fetch with HTTP 401 or 403 |
| `LocalNetworkAccessDenied` | `url`, `address_space` | A platform request would connect to a local or loopback address that `:local_network_access` doesn't allow |
| `AccessTokenExpired` | `expires_at`, `spec_ref` | OAuth access token has expired |

### Unknown

| Error | Fields | When |
|---|---|---|
| `TransportError` | `status`, `body`, `headers`, `url`, `spec_ref` | Ltix could not connect to the platform, or the endpoint URL is neither an absolute HTTPS URL nor an HTTP URL for a loopback or local host |
| `RedirectNotAllowed` | `status`, `url`, `location`, `spec_ref` | The platform redirected to a different origin or an invalid location |
| `KeySetNotStarted` | `key_set` | Raised when a launch needs a key set that is not started in the supervision tree |

## Logging recommendations

Invalid errors are usually the platform's fault (misconfiguration,
bad requests). Security errors could be attacks or clock skew. Unknown
errors need investigation.

```elixir
{:error, error} ->
  case error.class do
    :invalid ->
      Logger.info("LTI invalid request: #{Exception.message(error)}")

    :security ->
      Logger.warning("LTI security violation: #{Exception.message(error)}")

    :unknown ->
      Logger.error("LTI unexpected error: #{Exception.message(error)}")
  end
```
