Duffel.Client (Duffel v0.1.0)

Copy Markdown View Source

HTTP transport for the Duffel API.

Handles authentication, required headers, the data request/response envelope, error normalisation and cursor pagination. Resource modules (e.g. Duffel.OfferRequests) build on top of this module; most applications won't need to call it directly.

Errors

A failed request always comes back as {:error, %Duffel.Error{}}. That covers requests the API rejected and requests that never reached it: a connection or timeout failure becomes an error with type: :transport_error and the original exception under :reason.

Timeouts

A request waits 130 seconds for a response before giving up. Duffel allows order and booking creation to take up to 120 seconds, and recommends a client timeout slightly above that. Searches are quicker: each airline gets 20 seconds to answer by default, up to the 60 seconds supplier_timeout allows. Lower :receive_timeout on a client used only for searching:

Duffel.new(access_token: token, receive_timeout: 30_000)

A timeout is a transient failure, so it is retried like any other.

Retries and idempotency

A failed request is retried up to three times with a growing delay, but only when Duffel calls the failure retryable: a 408, 429 or 503, or a network error. Duffel documents 500 and 502 as "you should not retry this request", so neither is. A 504 can mean the supplier processed the request after all, so it is retried for GET and HEAD only, never for a POST that could book twice.

Retries still apply to every method, so each POST also carries an Idempotency-Key header. Duffel's API documentation never mentions idempotency keys, so treat the header as a precaution rather than a promise that a repeated POST is discarded — what stops a retry booking twice is the policy above. See post/4 for how to supply your own key.

Pass your own retry: in :req_options to replace this policy.

Telemetry

Every request emits a telemetry span under the [:duffel, :request] prefix:

  • [:duffel, :request, :start] - measurements %{system_time, monotonic_time}
  • [:duffel, :request, :stop] - measurements %{duration, monotonic_time}
  • [:duffel, :request, :exception] - when the request function raises

Metadata on every event: :method, :path and :base_url. The :stop event also carries :status (the HTTP status, or nil on a transport error), :result (:ok or :error) and :rate_limit (a Duffel.RateLimit, or nil when the response reported none).

Attach a handler to measure request latency:

:telemetry.attach(
  "duffel-logger",
  [:duffel, :request, :stop],
  fn _event, %{duration: duration}, meta, _config ->
    ms = System.convert_time_unit(duration, :native, :millisecond)
    Logger.info("duffel #{meta.method} #{meta.path} -> #{meta.status} (#{ms}ms)")
  end,
  nil
)

Summary

Functions

Performs a DELETE request.

Throws away the response body, reporting only whether the request succeeded.

Performs a GET request.

Performs a GET request and unwraps the resource from the data envelope. See get/3 and unwrap/1.

Performs a GET request against a list endpoint and wraps the result in a Duffel.Page.

Builds a client struct.

Performs a PATCH request, wrapping body in the data envelope.

Performs a PATCH request and unwraps the resource from the data envelope. See patch/4 and unwrap/1.

Performs a POST request, wrapping body in the data envelope the Duffel API expects.

Performs a POST request and unwraps the resource from the data envelope. See post/4 and unwrap/1.

Performs a PUT request, wrapping body in the data envelope.

Performs a PUT request and unwraps the resource from the data envelope. See put/4 and unwrap/1.

Lazily streams every item from a paginated list endpoint, following meta.after cursors until exhausted.

Takes the resource out of the data envelope Duffel wraps it in.

Types

response()

@type response() :: {:ok, map()} | {:error, Duffel.Error.t()}

t()

@type t() :: %Duffel.Client{
  access_token: String.t(),
  api_version: String.t(),
  base_url: String.t(),
  cards_base_url: String.t(),
  receive_timeout: timeout(),
  req_options: keyword()
}

Functions

delete(client, path, opts \\ [])

@spec delete(t(), String.t(), keyword()) :: response()

Performs a DELETE request.

discard(error)

@spec discard(response()) :: :ok | {:error, Duffel.Error.t()}

Throws away the response body, reporting only whether the request succeeded.

For endpoints that return nothing useful, such as a delete or an action that just acknowledges. Errors pass through untouched.

Examples

client |> delete("/air/webhooks/sev_123") |> discard()
#=> :ok

get(client, path, opts \\ [])

@spec get(t(), String.t(), keyword()) :: response()

Performs a GET request.

Options

  • :params - query string parameters. A list value is sent as one parameter per element, which is how Duffel's key[] array filters work: params: %{"passenger_name[]" => ["Amelia", "Earhart"]} sends passenger_name[]=Amelia&passenger_name[]=Earhart.

get_data(client, path, opts \\ [])

@spec get_data(t(), String.t(), keyword()) ::
  {:ok, term()} | {:error, Duffel.Error.t()}

Performs a GET request and unwraps the resource from the data envelope. See get/3 and unwrap/1.

list(client, path, params \\ [])

@spec list(t(), String.t(), keyword() | map()) ::
  {:ok, Duffel.Page.t()} | {:error, Duffel.Error.t()}

Performs a GET request against a list endpoint and wraps the result in a Duffel.Page.

Like unwrap/1, a success response whose data is missing or is not a list is an :unexpected_response error rather than an empty page.

new(opts)

@spec new(keyword()) :: t()

Builds a client struct.

Raises ArgumentError if :access_token is missing.

The token is hidden when the struct is inspected, so it does not reach logs or error trackers. Read client.access_token to get it back.

Examples

iex> client = Duffel.new(access_token: "duffel_test_abc")
iex> inspect(client) =~ "duffel_test_abc"
false

patch(client, path, body, opts \\ [])

@spec patch(t(), String.t(), map(), keyword()) :: response()

Performs a PATCH request, wrapping body in the data envelope.

patch_data(client, path, body, opts \\ [])

@spec patch_data(t(), String.t(), map(), keyword()) ::
  {:ok, term()} | {:error, Duffel.Error.t()}

Performs a PATCH request and unwraps the resource from the data envelope. See patch/4 and unwrap/1.

post(client, path, body, opts \\ [])

@spec post(t(), String.t(), map(), keyword()) :: response()

Performs a POST request, wrapping body in the data envelope the Duffel API expects.

Every POST carries an Idempotency-Key header. One is generated unless you pass your own. Pass idempotency_key: nil to send no key at all.

Duffel's API documentation does not describe how it treats this header, so do not count on it to collapse two identical bookings. Supply your own key when the caller may retry the same logical operation across processes or deploys, and check whether the resource already exists before retrying a create yourself.

Options

  • :params - query string parameters
  • :idempotency_key - value for the Idempotency-Key header. Defaults to a generated key; nil sends no header.

post_data(client, path, body, opts \\ [])

@spec post_data(t(), String.t(), map(), keyword()) ::
  {:ok, term()} | {:error, Duffel.Error.t()}

Performs a POST request and unwraps the resource from the data envelope. See post/4 and unwrap/1.

put(client, path, body, opts \\ [])

@spec put(t(), String.t(), map(), keyword()) :: response()

Performs a PUT request, wrapping body in the data envelope.

put_data(client, path, body, opts \\ [])

@spec put_data(t(), String.t(), map(), keyword()) ::
  {:ok, term()} | {:error, Duffel.Error.t()}

Performs a PUT request and unwraps the resource from the data envelope. See put/4 and unwrap/1.

stream(client, path, params \\ [])

@spec stream(t(), String.t(), keyword() | map()) :: Enumerable.t()

Lazily streams every item from a paginated list endpoint, following meta.after cursors until exhausted.

Raises Duffel.Error if any page request fails, or if Duffel hands back the cursor it was just given, which would otherwise fetch the same page forever.

unwrap(error)

@spec unwrap(response()) :: {:ok, term()} | {:error, Duffel.Error.t()}

Takes the resource out of the data envelope Duffel wraps it in.

A success response with no data key becomes an :unexpected_response error rather than being handed back as if the envelope were the resource. Errors pass through untouched.

get_data/3, post_data/4, patch_data/4 and put_data/4 do this for you; reach for unwrap/1 directly only when you have built the request some other way.

Examples

client |> get("/air/orders/ord_123") |> unwrap()
#=> {:ok, %{"id" => "ord_123", ...}}