Call an External Service
The shop sends its emails through a mail provider with an HTTP API. An operation reaches such an
API with context.fetch. Every component has it; nothing is declared.
§Make the request
notifications is a component of its own, with one operation. Any application could send its
mail through it, so it has no namespace: its manifest states a name alone. send is an effect, the type of an operation that acts
outside the component:
// components/notifications/operations/send.ts
import type { Context, SendInput } from '../types/index.d.ts'
export async function effect (input: SendInput, context: Context) {
const response = await context.fetch('https://mail.example.com/messages', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ to: input.to, text: input.text })
})
if (response.status === 422) return new Error('REJECTED')
if (!response.ok) throw new Error(`Mail provider answered ${response.status}`)
const { id } = await response.json()
return { id }
} # components/notifications/manifest.toa.yaml
name: notifications
operations:
send:
input:
type: object
properties:
to: { type: string }
text: { type: string }
required: [to, text]
errors: [REJECTED] context.fetch takes what the standard fetch takes — a URL, a URL object or
a Request, and the usual options — and answers a standard Response.
Two habits to keep:
- Decide what a status means.
context.fetchdoes not throw on4xxor5xx. Return an error for an answer that is a refusal your operation declares, and throw for one that is a failure. - Read or cancel the body. A response whose body is neither read nor cancelled
(
response.body.cancel()) keeps its connection busy.
Responses are not cached.
§Address and key
The address of the provider and the key for it are configuration of the component, the key declared as a secret. Add
to the manifest, and to the configuration of the Context:
# components/notifications/manifest.toa.yaml
configuration:
schema:
properties:
url: { type: string, format: uri }
key: { type: string, format: secret }
required: [url, key] # context.toa.yaml
configuration:
identity.tokens:
# …
notifications:
url: https://sandbox.mail.example.com
url@production: https://mail.example.com
key: $MAIL_KEY // components/notifications/operations/send.ts
import type { Context, SendInput } from '../types/index.d.ts'
export async function effect (input: SendInput, context: Context) {
const { url, key } = context.configuration
const response = await context.fetch(`${url}/messages`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${key.unwrap()}`
},
body: JSON.stringify({ to: input.to, text: input.text })
})
// ...
} A secret is not a string; unwrap() gives its value where it is used. For a local run npm run env writes an empty TOA_CONFIGURATION__MAIL_KEY= into .env for you to fill in, and
keeps what you filled in when it writes the file again; a deployment takes the value from a
secret. Both are described in Configuration.
§Try it
$ npm run env Put the key of the provider’s sandbox into .env, then restart and call the operation:
$ npm run restart
$ npx toa call notifications.send "{ input: { to: 'ann@example.com', text: 'Hello' } }" The reply is { id } as the provider answered it. An address the provider rejects answers the
error REJECTED; a provider that is down makes the call fail with an exception.
§Run without the provider
A developer’s machine and a test run should not need the provider’s key, and should not send real
emails. Put the request behind a function of the component, and let a stub answer when the key is
empty, which is what a freshly generated .env holds:
// components/notifications/lib/mail.ts
import type { Context, SendInput } from '../types/index.d.ts'
export async function deliver (message: SendInput, context: Context) {
const key = context.configuration.key.unwrap()
if (key === '') return stub(message, context)
const response = await context.fetch(`${context.configuration.url}/messages`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
body: JSON.stringify(message)
})
if (response.status === 422) return new Error('REJECTED')
if (!response.ok) throw new Error(`Mail provider answered ${response.status}`)
return await response.json()
}
function stub (message: SendInput, context: Context) {
if (context.env !== 'local') throw new Error('The key of the mail provider is not configured')
context.logs.info('Mail not sent, the stub answers', { to: message.to })
if (message.to.endsWith('@rejected.example')) return new Error('REJECTED')
return { id: context.id() }
} // components/notifications/operations/send.ts
import type { Context, SendInput } from '../types/index.d.ts'
import { deliver } from '../lib/mail.ts'
export async function effect (input: SendInput, context: Context) {
return await deliver({ to: input.to, text: input.text }, context)
} Three things make this arrangement work:
- The stub answers only in the local environment. Anywhere else a missing key is a failure,
so a deployment that forgot its secret does not quietly send nothing.
context.envis the environment the application runs as; it,context.logsandcontext.id()are members of the operation context. - The stub answers what the provider would, in the same shape, including its refusals. Let
the input choose the outcome, as the
@rejected.exampleaddress does, and a test can cover the refusal without the provider. - Shared code lives outside
operations/, here inlib/: every file inoperations/is an operation.
A test then runs against the stub with no setup:
$ npm run restart
$ npx toa call notifications.send "{ input: { to: 'ann@rejected.example', text: 'Hello' } }" answers the error REJECTED, and any other address answers an id. Scenarios that reach the
component through other operations or over HTTP work the same way; see Write a Test.
To run locally against the real provider, fill the key in .env and run npm run restart.
§Retries
Retries are off unless asked for with retry among the options:
const response = await context.fetch(`${url}/messages`, {
method: 'POST',
body: JSON.stringify(message),
retry: { attempts: 3 }
}) | Option | Default | |
|---|---|---|
attempts | required | How many times the request is made at most, the first one included. |
expected | 200–299 | The statuses that end the attempts. Any other is tried again. |
delay | 100 | Milliseconds before the second attempt. |
factor | 2 | What the delay is multiplied by after each attempt. |
const response = await context.fetch(`${url}/messages/${id}`, {
retry: { attempts: 5, expected: [200, 404], delay: 200, factor: 3 }
}) - An attempt is repeated after an unexpected status and after a network failure.
- A
Retry-Afterheader in the response is followed instead of the calculated delay. - When the attempts run out on an unexpected status, that last
Responseis returned. Check the status even with retries on. - When they run out on a network failure, the error is thrown.
- You do not see the responses in between.
A request whose body is a ReadableStream, or a Request object that already carries a body,
cannot be sent twice. Asking for more than one attempt with such a body throws a TypeError:
Cannot retry a request with a non-replayable body Such bodies work without retry, or with attempts: 1.
The provider sees every attempt. Retry only requests it can safely receive twice, or send it a key it recognizes repeats by.
§Timeouts
context.fetch has no timeout of its own. Give the request a signal, as with the standard fetch:
const response = await context.fetch(`${url}/messages`, {
method: 'POST',
body: JSON.stringify(message),
retry: { attempts: 3 },
signal: AbortSignal.timeout(10_000)
}) The signal covers the whole call: when it aborts, a wait between attempts ends too, and the call throws.
§When the operation runs again
An operation reached by an event or a task is run again when it throws, and its requests are made again with it; see Handle Repeated Calls. So:
- Keep
attemptsand delays small: they are for a short hiccup inside one run. A longer outage should fail the operation, which is then tried again as a whole. - Give the provider a key it recognizes a repeated request by, where it accepts one.
- An effect that makes several requests in a row, and should not repeat the ones that succeeded, is a multi-step workflow.
§What is recorded
Every call is traced without any code: one span named after the method and the origin, with a nested span for each attempt made.
POST https://mail.example.com
├── attempt 1
├── attempt 2
└── attempt 3 The path, the query string, headers and bodies are not recorded, so keys and personal data in them do not reach the telemetry backend.
With metrics configured, three are recorded per origin, one observation per attempt:
| Metric | Type | Labels |
|---|---|---|
toa.fetch.duration | histogram, seconds | origin, method |
toa.fetch.responses | counter | origin, method, status |
toa.fetch.retries | counter | origin |
See Traces for turning tracing on.