Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: External services

External services

Calling

Your app reaches an outside service, such as a courier's API, through a connection: a named entry under connections: in app.jadpo that says which package to use, which of your domains may use it…

On this page

Your app reaches an outside service, such as a courier's API, through a connection: a named entry under connections: in app.jadpo that says which package to use, which of your domains may use it and which credential it sends (Connecting in app.jadpo). A domain is one business area of your app, in its own folder under domains/. The example below declares a connection named couriers.

There are two kinds of package (Kinds of package):

  • A provider package describes one outside API at one version: its operations, grouped into resources such as bookings, and their types and failures. You call it through a connection.
  • A library package is plain shared code with no service behind it, such as a postcode parser. You call it through the package's name.

Calling a provider

You call a provider's operation as connection.resource.operation(input). couriers.bookings.create(request) calls the create operation of the bookings resource, through the connection couriers. A file brings the connection in with use app { couriers }. A provider package's public action is called the same way, as connection.action(input).

Only actions may call either. An action is code that may change things, such as writing records or calling outside, unlike a function, which only computes a value (Functions and actions).

A call can fail, so you write it with attempt or a match. attempt passes a failure on to whoever called your action (Passing a failure on: attempt). After the call you list how each of the package's failures becomes one of your app's own, written condition: Failure, as in Turning one failure into another.

This sketch books a collection for a Shipment, a stored record of your app that isn't shown, and saves the booking's id on it. The complete example under A call, step by step compiles.

example
// domains/shipping/actions/book_collection.jadpoaction book_collection(shipment: Shipment) fails CollectionSlotGone -> Shipment {    var request: AcmeCouriers.CreateBooking = AcmeCouriers.CreateBooking {        postcode: shipment.postcode        slot_id: shipment.slot    }    var booking: AcmeCouriers.Booking = attempt couriers.bookings.create(request) {        slot_taken: CollectionSlotGone    }    return attempt Shipment.update(shipment) {        set: {            booking_id: booking.id        }    }}

slot_taken is the code of one of the failures the package declares for the operation, under its failures:.

Calling a library package

A library package's functions are called through the package's name, with no connection, from anywhere a library may be called:

example
// domains/accounts/actions/save_address.jadpouse packages/postcodesvar postcode: Postcodes.UkPostcode = attempt Postcodes.uk.parse(input.postcode) {    invalid_postcode: AddressInvalid}

Types

The types come from the package, and your app writes none. The checker compares what you send with what the operation takes. At runtime a response is checked only for the fields your code reads, so a provider adding or changing other fields breaks nothing. A package input may take any of your app's object types, sent as JSON, such as Postmark's template variables or Stripe's metadata.

Failures

Map a package's failure at the call to give it your app's meaning. A package failure you don't map becomes a fault: a problem outside your code that the runtime raises, which never appears in your fails list (Faults). To change a package failure's code, not just its message, map it to your app's own failure at the call.

The runtime also raises failures of its own on any outside call, whatever the package declares:

  • misconfigured when the provider rejects the credential;
  • unavailable when the provider surely did nothing;
  • outcome_unknown when the provider may have acted.

A call that gets no answer in time is timed_out. You may map timed_out to one of your own unavailable failures only on an operation that is safe to send again (Retrying a call, and timing out).

Idempotency keys

An idempotency key lets a provider recognise a request sent twice and act on it once. The package says where the key goes, and the runtime supplies it. You don't choose the key yourself.

Each call's key is built from what identifies this call. The action's key is the client's Idempotency-Key header or the request's id in a route (Idempotent writes), and the delivery key in subscribers and schedules, which names the delivery being handled (Delivery: at least once, safe to repeat).

The runtime never sends that key as it is. It hashes it with the application, the environment, the connection, the caller's scope (who is calling, so one person's key never matches another's), the call's step number and a key generation. The result is jd1_ followed by 32 base32 characters of a SHA-256, 36 characters in all. Where the provider takes fewer, it is cut to the package's max_length:.

So when two callers send the same Idempotency-Key, a provider that keys requests by account never takes one for a repeat of the other.

Time limits and retries

By default a call may take 3 seconds (timeout:) and is tried up to 3 times within 30 seconds (retry: { attempts: within: }). The wait between tries doubles each time, with some randomness (exponential backoff with full jitter): at most 200 milliseconds before the second try and 400 before the third.

A route has 10 seconds by default, its deadline, which the route's deadline: changes (Time limits). Three 3-second tries and their waits take less than 10 seconds, so a route making one call needs no deadline: of its own. Retrying never runs past the route's deadline: a wait that would end after it stops the retrying, and the last answer stands. In background work, retrying stops at within: or when the work's own time budget runs out, whichever comes first.

Only safe repeats are retried: the request never left, the provider said it did nothing (honouring Retry-After), or the call is idempotent. Retrying a call, and timing out has the details.

The package's rate limits space calls out before the provider refuses them. max_concurrent: caps how many calls run at once per app instance. Further calls wait, and a subscriber delivery that is waiting is retried later (How polls and max_concurrent: run). Heavy work such as PDF rendering runs in subscribers, never in the route.

Pages of results

A list operation returns a Page<T> (Pages). The package says how the provider pages, and page.next asks for the next page. Work over many pages uses batches.each(from:, each:, finished:, key:), which pages through in the background and resumes where it stopped (Batches: work over many items).

Calls and writes

In an action, calls that change something come before any write. The writes commit after the last call returns. If the commit fails, the action runs again, and so do its calls, under the same keys. No transaction is open while a call is out. Reads before the call use a snapshot closed before the call, and a write to a record loaded before it checks the row is unchanged (Outside calls hold no transaction).

A changing call with no idempotency key must be the only changing call in its action. Several keyed calls follow Several changing calls in one action. Work that must follow a write emits an event (Emitting an event).

A call, step by step

Here an action calls an operation through the connection couriers, declared in app.jadpo. The action is in shipping, a domain the connection lists. It builds the package's own input type through the package's name. It maps the package's slot_taken failure to the app's CollectionSlotGone, a failure of the same kind (conflict). The runtime's driver for the package's protocol, native code that speaks JSON over HTTP, sends the call; how it is sent and read is under Decoding.

example
use app { couriers }use shipping/failures { CollectionSlotGone }use shared/providers/acme_couriers/// Books a collection, mapping a slot the courier has given away to the/// shop's own failure.action book_collection(    postcode: AcmeCouriers.Postcode,    slot: AcmeCouriers.SlotId) fails CollectionSlotGone -> AcmeCouriers.Booking {    var request: AcmeCouriers.CreateBooking = AcmeCouriers.CreateBooking {        postcode: postcode        slot_id: slot    }    return attempt couriers.bookings.create(request) {        slot_taken: CollectionSlotGone    }}
  • Mapped at the call. Each arm of the call's attempt is keyed by the code of one of the operation's failures:, and names the app's failure of the same kind. The action fails with the app's failure, and the provider's failure is kept as its internal cause, for the logs. A failure no arm maps is a fault when it comes, and is never passed on, so a package failure is never in the app's fails list. A match on the call handles the package's failures by name, as for a library function.
  • The runtime's own failures come as they would for any outside call. misconfigured means the provider rejected the credential, or the address is one the runtime won't send to.
  • unavailable means the call can safely be made again. The provider surely did nothing: it answered 408 or 429, or said the key is still in use, or the call never left. A call with an idempotency key that may have acted is unavailable too, since sending it again under the same key is safe.
  • outcome_unknown means a call with no key may have acted: the provider answered with a server error, or didn't answer in time.
  • Sent once no transaction is open. The call goes out through the capability seam, the one place in the runtime that makes outside calls, where each call is logged and keyed. What the action has only read before the call is a snapshot, closed before the call goes out. Its writes run in a transaction opened after the call, which holds each record loaded before the call to the row version it was loaded at (Outside calls hold no transaction).
  • A call after a write. A call made after the action's first write is never sent: it is the fault call_in_transaction. jadpo check refuses a write placed before a later call (data.write_before_call), so checked code never meets the fault. If a record changes while the call is out, the action runs again, and the call is repeated under its key. An item of a batch whose each: action calls a provider is a batch of its own (How a run is kept).

Retrying a call, and timing out

Here the connection sets timeout: 5s, so each try may take five seconds, and retry:, which tries the courier up to four times within 20 seconds. Looking up a parcel changes nothing, so it is safe to send again, and the action maps timed_out: at the call. A lookup the courier never answers in time, on any try, is the shop's own CourierUnavailable.

example
application {    connections: {        couriers: {            package: AcmeCouriers.v1            domains: [shipping]            credential: config.couriers_api_key            environment: config.couriers_environment            timeout: 5s            retry: {                attempts: 4                within: 20s            }        }    }}
example
use app { couriers }use shipping/failures { CourierUnavailable }use shared/providers/acme_couriers/// Where a parcel is, or the shop's own failure when the courier doesn't/// answer in time.action parcel_status(    parcel: AcmeCouriers.ParcelRef) fails CourierUnavailable -> AcmeCouriers.Status {    var tracked_parcel: AcmeCouriers.Parcel = attempt couriers.parcels.get(        AcmeCouriers.ParcelLookup {            id: parcel        }    ) {        timed_out: CourierUnavailable    }    return tracked_parcel.status}
  • What is sent again. A call that never left, or that the provider said did nothing (408, 429, its key still in use), whatever it changes; and one that may have acted (a server's error, no answer in time) only when it changes nothing or takes its package's key, sent again unchanged under the same key. A change with no key that may have acted is never sent again: it is outcome_unknown.
  • How long it waits between. 200 milliseconds before the second attempt, doubled before each after it, each wait drawn evenly from zero to that (full jitter); a provider's Retry-After in seconds instead, where it gives one. A wait that would end after within:, or after what is left of the route's deadline or of background work's budget, ends the retrying, and the last answer stands. Each attempt is logged as a call of its own.
  • timed_out: turns the runtime's timed_out into one of the app's own failures of kind unavailable, once every attempt has gone unanswered in time. The runtime's timed_out is kept as its cause. You can map it only on an operation that changes nothing or is idempotent: true. Mapping it on an operation that changes something with no key is the error failure.unknown_mapping.
  • A timeout you don't map is unavailable where the call was safe to send again, and outcome_unknown where it wasn't.

Markdown for agents: /docs/calling.md.Built from Jadpo main at a707efb.