II Business Logic

Refuse a Request

An order cannot be approved when the customer’s wallet holds too little, and whoever asked must be told why. An operation says “no” by returning an Error whose message is a code, and by declaring that code in the manifest.

§Returning an error

billing.wallets keeps a customer’s money in the shop: one wallet per customer, under the customer’s id. Its entity is associated, and gains paid: what the wallet has taken money for, and how much.

# components/billing.wallets/manifest.toa.yaml
name: wallets
namespace: billing

entity:
  associated: true
  properties:
    balance: { type: integer, minimum: 0 }
    paid:
      type: object
      additionalProperties: { type: integer, minimum: 1 }
  required: [balance]
  blank:
    balance: 0
    paid: {}

debit takes an amount off the balance, unless the balance is too low. It is told what the money is for, in ref, and takes it once for each ref:

// components/billing.wallets/operations/debit.ts
import type { DebitInput, State } from '../types/index.d.ts'

export async function transition (input: DebitInput, entry: State) {
  if (entry.paid[input.ref] !== undefined) {
    entry.DISCARD = true // asked again for the same thing: the money is taken

    return { balance: entry.balance }
  }

  if (entry.balance < input.amount)
    return new Error('INSUFFICIENT_FUNDS')

  entry.balance -= input.amount
  entry.paid[input.ref] = input.amount

  return { balance: entry.balance }
}

The first lines make debit safe to ask twice. Whoever calls it may run more than once for one request, and calls it each time; a debit whose ref the wallet holds stores nothing and answers the balance, as the first one did.

§Declaring errors

Add the operation to the manifest, below the entity:

# components/billing.wallets/manifest.toa.yaml
operations:
  debit:
    concurrency: retry
    query: true
    input:
      type: object
      properties:
        amount: { type: integer, minimum: 1 }
        ref: { type: string } # what the money is for: an order
      required: [amount, ref]
    errors: [INSUFFICIENT_FUNDS]

This is the smallest form of debit. A Wallet and Its Ledger writes the whole wallet, with money set aside and a line of history for every movement.

Try it on a wallet that holds nothing:

$ npm run restart
$ npx toa call billing.wallets.debit "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { amount: 1500, ref: 'order-1' } }"

The command prints the error, which carries the code, and exits with 0 — a refusal is a reply:

CodedError: INSUFFICIENT_FUNDS
    …
  code: 'INSUFFICIENT_FUNDS'

Over HTTP the same refusal is a 422 whose body is { code }.

A returned error:

  • Stores nothing. A transition or an assignment that returns one leaves the state as it was, and no event is published.
  • Is not a failure. Nothing is retried, logged as a failure or alarmed by it.
  • Must be declared. On a local run, returning a code that is missing from errors fails the call with an exception instead of handing an unknown code to the caller. Declare the code.
  • Has a message. new Error() has no code, and returning it is an exception.

§Telling the caller more

The customer should see how much is missing. Whatever the caller needs besides the code goes in cause:

if (entry.balance < input.amount)
  return new Error('INSUFFICIENT_FUNDS', { cause: { missing: input.amount - entry.balance } })
CodedError: INSUFFICIENT_FUNDS
    …
  code: 'INSUFFICIENT_FUNDS',
  cause: { missing: 1500 }

The code and the cause are all that reaches the caller: any other property set on the error is dropped, and message repeats the code. cause travels as JSON, so put in it only what JSON can hold. Over HTTP the body is { code, cause }.

§Handling an error from a call

A call resolves to the output or to the error. Tell them apart with instanceof. store.orders.approve, the transition Create a Component writes, charges the customer’s wallet before it approves the order. The order’s customer is the customer’s id, which is the wallet’s id, so it is an identifier of 32 hexadecimal characters:

// components/store.orders/operations/approve.ts
import type { Context, State } from '../types/index.d.ts'

export async function transition (input: null, entry: State, context: Context) {
  if (entry.status !== 'pending')
    return new Error('NOT_PENDING')

  const reply = await context.remote.billing.wallets.debit({
    query: { id: entry.customer },
    input: { amount: entry.total, ref: entry.id }
  })

  if (reply instanceof Error)
    return reply

  entry.status = 'approved'

  return entry
}

The order’s id is the ref: the debit is “the payment for this order”, whoever asks for it and however often.

Returning the error passes it on as this operation’s own answer, so its code must be among this operation’s errors too:

# components/store.orders/manifest.toa.yaml
operations:
  approve:
    concurrency: retry
    query: true
    errors: [NOT_PENDING, INSUFFICIENT_FUNDS]

approve stays concurrency: retry. Run again because the order changed under it, it calls debit again with the same ref, and the wallet takes nothing the second time. Handle Repeated Calls has the technique.

To answer something else, read reply.code:

if (reply instanceof Error) {
  if (reply.code === 'INSUFFICIENT_FUNDS')
    return new Error('PAYMENT_DECLINED')

  return reply
}

What the operation added is in reply.cause: here reply.cause.missing.

In a test the check is the same:

const orders = await remote('store.orders')
const reply = await orders.invoke('approve', { query: { id } })

assert.ok(reply instanceof Error)
assert.equal(reply.code, 'INSUFFICIENT_FUNDS')

§Exceptions

Anything thrown is a failure nobody chose: a bug, or something the operation depends on being away. Do not throw on purpose, and declare nothing for it.

You will meet exceptions that are thrown for you:

The callOver HTTP
does not fit the operation’s input or query400
selects an object that does not exist404
names a version the object is no longer at412
lost a race with concurrency: none, or is a refused duplicate409
went unanswered for too long504
failed in any other way, including a reply that does not fit output, a state that does not fit the entity schema and a refusing guard500

toa call prints an exception to stderr and exits with 1. The gateway’s side is in Requests and Responses.

An exception from a call is thrown where the call was made:

const reply = await context.remote.billing.wallets.debit(request) // may throw

Do not catch it unless you can truly answer in its place. An operation that lets it through fails in turn, which is usually right. An operation that swallows it returns a wrong answer, and nothing retries a wrong answer.

§Three rules

Do not throw to refuse. Where nobody waits for the reply — a receiver, a task — a thrown refusal is tried again and again, then set aside as though something were broken. A returned error ends the work.

Do not return an error for a failure. A returned error says that everything worked. Nothing will be tried again.

Do not assume once. An operation may run more than once for one call or one event; see Handle Repeated Calls.

Replies in the model explains why the two are kept apart.

§Deciding not to write

A customer is charged for a subscription only if the wallet’s balance allows; if not, that is a normal outcome with an answer of its own, not a refusal. Set DISCARD on the state:

// components/billing.wallets/operations/charge.ts
import type { ChargeInput, State } from '../types/index.d.ts'

export async function transition (input: ChargeInput, entry: State) {
  if (entry.balance < input.amount) {
    entry.DISCARD = true

    return { balance: entry.balance, charged: false }
  }

  entry.balance -= input.amount

  return { balance: entry.balance, charged: true }
}
# components/billing.wallets/manifest.toa.yaml
operations:
  charge:
    concurrency: retry
    query: true
    input:
      type: object
      properties:
        amount: { type: integer, minimum: 1 }
      required: [amount]
$ npm run restart
$ npx toa call billing.wallets.charge "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { amount: 1500 } }"
{ balance: 0, charged: false }

Nothing is written, VERSION does not grow, no event is published, and the caller receives the output. Whatever the function changed on a discarded state is dropped without being validated.

Over entries, an entry flagged DISCARD is left out of the write and the rest are stored.

§The four outcomes

The operationStateThe caller gets
Outputreturns a valuestoredthe value
Discardreturns a value, with DISCARD setuntouchedthe value
Errorreturns new Error('CODE')untouchedan Error with code and cause
Exceptionthrowsuntouchedan exception thrown at the call
Search
Search the documentation