Octov0.11.7
ReferenceConnectors

HTTP

The HTTP server connector, HTTP client connector, and the rest blocks.

Octo ships two HTTP connectors. http is a server connector whose sources turn inbound requests into flow executions and write the flow's result back as the response. http-client is an outbound client that the rest block runs requests through.

http connector

The http connector (label: HTTP Server) owns a single HTTP server: host, port, base path, server timeouts, and CORS. Each flow fronted by an http source registers a route on that shared server. It provides the HTTP route source (below) and no blocks; rest binds to http-client instead.

Settings

SettingTypeRequiredDefaultDescription
hoststringNo$HTTP_HOST, else 0.0.0.0Bind address. An explicit value wins over the HTTP_HOST environment variable.
portintNo$HTTP_PORT, else 8080Bind port. An explicit 0 lets the OS pick a free port. An explicit value wins over the HTTP_PORT environment variable.
basePathstringNounsetPrefix for all routes (e.g. /api/v1).
keepAlivebooleanNounsetEnable HTTP keep-alives.
requestTimeoutdurationNo30sHow long a handler waits for the flow to finish.
readTimeoutdurationNounsetServer read timeout.
writeTimeoutdurationNounsetServer write timeout.
idleTimeoutdurationNounsetServer idle timeout.
corsobjectNounsetCross-origin resource sharing. Inert unless allowedOrigins is set.

Duration settings accept Go duration strings (5s, 250ms).

CORS settings (cors)

CORS is off unless allowedOrigins is non-empty. When set, the connector answers OPTIONS preflights and adds CORS headers on every route.

SettingTypeRequiredDefaultDescription
allowedOriginsstring[]NounsetOrigins allowed to make cross-origin requests. Exact matches; a single * allows any origin.
allowedMethodsstring[]NoGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONSMethods allowed on preflight.
allowedHeadersstring[]Noecho requested headersRequest headers allowed on preflight; when unset, the requested headers are echoed back.
exposedHeadersstring[]NounsetResponse headers exposed to the browser on actual responses.
allowCredentialsbooleanNofalseAllow credentialed (cookie/authorization) requests. When true, the origin is echoed rather than *, per the CORS spec.
maxAgedurationNounsetHow long a browser may cache the preflight response.

http source

Each http source binds one route pattern to a flow. The request becomes the flow's input message; the flow's final message becomes the response.

SettingTypeRequiredDefaultDescription
pathstringYesnoneRoute pattern; {name} params become vars.name.
methodsstring[]Nounset (every method)HTTP methods the route answers. Naming any means every other method gets a 405 (with an Allow header) from the router, before any flow runs. Listing GET also answers HEAD, and the flow runs for it. A name that is not an HTTP method fails at startup.
headersstring[]NounsetRequest header names to copy into variables.
responseHeadersstring[]NounsetResponse header names to propagate from message variables of the same name after the flow completes. Content-Type is managed by the source.
correlationIdHeaderstringNounsetHeader to source the correlation ID from.
rawBodyVarstringNounsetCapture the exact request bytes into vars.<name> (e.g. to verify an HMAC signature over the raw body).
rawBodybooleanNofalseSource the request as raw content: skip the JSON check and store the body as {contentType, rawData} using the request's Content-Type. Lets non-JSON payloads (forms, XML, binary text) enter the flow. A multipart/form-data request is decoded either way, see Multipart bodies.
timeoutdurationNoconnector's requestTimeoutPer-route wait for the flow. On an SSE route this bounds the wait for the first frame instead.
maxBodyBytesintNo1048576Request body size cap (via http.MaxBytesReader). Oversized requests get 413.
sseobjectNounsetServe the route as a server-sent event stream. Inert unless sse.enabled.

How the request maps into the message

Request partLands in
Path parametersEvery {name} in path lands in vars.name (e.g. /orders/{id} sets vars.id).
Methodvars.method holds the HTTP method, so a flow can route on vars.method == "POST".
Query stringvars.query is a map of query parameters (first value of each key). It is always present, possibly empty.
HeadersEach header listed in headers is copied into a variable of the same name, e.g. vars["X-Tenant"].
BodyA JSON body becomes body. A malformed JSON body is rejected with 400 before the flow runs, unless rawBody: true, in which case the body enters the flow as raw content carrying the request's Content-Type. A multipart/form-data body is decoded into body.parts regardless of rawBody (see Multipart bodies). An empty body is valid (body stays null).
Raw bytesWith rawBodyVar set, the exact request bytes are also stored in that variable as a string.

How the response is written

A completed flow returns its final body as JSON with status 200, or as raw content with its own Content-Type when the body is raw content. Setting vars.httpStatus to a valid code (100 to 599) overrides the response status. Variables named in responseHeaders are emitted as response headers (scalar values only).

A dropped message (e.g. a filter matched nothing) responds 204 No Content. A failed flow responds 500 with a JSON {"error": ...} body, see error handling. A flow that exceeds the timeout responds 504.

Multipart bodies

A request whose Content-Type is multipart/form-data is decoded automatically: its parts land on body.parts, reachable by name.

source:
  connector: api
  type: http
  settings:
    path: /upload
    methods: [POST]
    maxBodyBytes: 10485760      # raise this for uploads; the default is 1 MiB

curl -F 'username=ann' -F 'avatar=@photo.png' localhost:8080/upload gives the flow a body of:

{
  "contentType": "multipart/form-data; boundary=------abc123",
  "rawData": "--------abc123\r\nContent-Disposition: form-data; name=\"username\"...",
  "parts": {
    "username": { "name": "username", "filename": "", "contentType": "",
                  "encoding": "text",   "size": 3,     "data": "ann", "headers": {} },
    "avatar":   { "name": "avatar", "filename": "photo.png", "contentType": "image/png",
                  "encoding": "base64", "size": 12345, "data": "iVBORw0K...", "headers": {} }
  }
}

There is no setting to enable this, and rawBody does not turn it off. parts is added alongside contentType and rawData, which stay exactly what was received, so one flow can verify an HMAC over the raw body and read body.parts.avatar. A part with a filename always has encoding: base64; a plain form field is always text. Read binary data with base64.decode:

- type: set-payload
  settings:
    value: 'base64.decode(body.parts.avatar.data)'

size is always the decoded byte length. A repeated part name becomes a list, so body.parts.tag[0].data reaches the first. A body that claims to be multipart but will not parse is rejected with 400 before the flow runs, like a malformed JSON body.

A decoded upload is held in memory twice: once as rawData and once as the parts, where base64 costs about 4/3 of the original bytes. A 10 MiB upload occupies roughly 23 MiB per message, and more again if the flow forks or crosses a queue, so set maxBodyBytes deliberately.

To send a multipart body, see the rest block's bodyType. The multipart CEL functions build and decode one anywhere else.

The runtime reads the whole request into memory and writes the whole response at once. See Raw Content and Streaming for non-JSON payloads, or server-sent events to push output incrementally.

Example

Adapted from samples/http-orders.yaml:

service:
  name: http-orders

env:
  - name: HTTP_PORT
    default: "8080"

connectors:
  - name: api
    type: http
    settings:
      port: ${HTTP_PORT}
      basePath: /api/v1
      requestTimeout: 5s

flows:
  - name: orders-api
    source:
      connector: api
      type: http
      settings:
        path: /orders/{id}            # {id} -> vars.id
        correlationIdHeader: X-Request-Id
        headers: [X-Tenant]           # captured as vars["X-Tenant"]
    process:
      - type: set-payload
        settings:
          value: '{"orderId": vars.id, "tenant": vars["X-Tenant"], "status": "found"}'
curl -s localhost:8080/api/v1/orders/42 -H 'X-Tenant: acme'

Server-sent events

Setting sse.enabled on a route turns it into a server-sent event stream: the connection stays open and blocks push frames to the caller with sse-event, instead of the flow returning one buffered response.

SettingTypeRequiredDefaultDescription
sse.enabledbooleanNofalseServe this route as an event stream.
sse.streamVarstringNosseStreamVariable the stream's address lands in.
sse.finalEventbooleanNofalseEmit the flow's response message as one last frame before closing.
sse.finalEventNamestringNounsetThe event: name for that final frame.
sse.closeOnCompletebooleanNotrueClose the stream when the flow's own invocation terminates.
sse.heartbeatdurationNo15sHow often an idle stream writes a : ping comment. 0 means the default, not "off".
sse.maxDurationdurationNounlimitedHard cap on how long one stream may stay open.
sse.maxStreamsintNounlimitedConcurrent streams this route holds open; over the cap, requests get 503.

The stream address

A stream is addressed by an id of its own, minted when the route accepts the request and published in vars.sseStream as <connector>:<id>. It is not the message's EventID, which is rekeyed whenever a message crosses into another invocation (flow-ref, split and queue-dispatch all do it).

The address is an ordinary string in an ordinary variable, so it survives every copy, rekey and serialization: an sse-event in a flow-ref'd sub-flow writes to the caller's connection with no configuration. Because it names its own connector, a flow can hand it somewhere else entirely:

# Hand the stream to a queued job, which writes progress back to the same caller.
- type: queue-dispatch
  settings:
    subject: '"jobs.render"'

The worker points the block's stream setting at wherever the address landed, say body.stream.

A stream is a live connection on one process. A queued job picked up by another replica resolves the address and finds nothing, landing on the block's ifClosed policy. Cross-replica streaming is not supported yet.

The response is committed by the first frame

Nothing is written until a block emits, so everything before that behaves like an ordinary route. A filter that rejects (jwt-validate, validate) still returns a real 401/400 with its own body. vars.httpStatus and responseHeaders still apply, and responseHeaders are read off the message that emits the first frame. A flow that emits nothing gets the ordinary buffered JSON response. A dropped message responds 204, and a failure before any frame is an ordinary 500.

Once a frame goes out the response is a committed 200 text/event-stream, so a failure can only be told in-band: an unhandled failure sends event: error with a {"error": ...} payload and closes. Put an sse-event in the flow's error: chain to say something else instead.

When the stream closes

Whichever comes first: an sse-event with close: true; the flow's terminal event (unless closeOnComplete: false); the client disconnecting; maxDuration; or connector shutdown.

closeOnComplete: false is for a flow that hands its work off and finishes early (a split, or a queue-dispatch), where the detached invocations are the ones with something to say. Pair it with maxDuration, or a stream nothing closes leaks, and raise timeout to cover the wait for the first frame.

The client disconnecting does not cancel the flow; a flow keeps running once it has accepted a message. The block's default ifClosed: stop is how a flow notices and stops producing output nobody will read.

sse-event block

Writes one frame to an open stream.

SettingTypeRequiredDefaultDescription
eventstringNounsetThe event: name. Empty means an unnamed event, which browsers deliver to EventSource.onmessage.
dataCELNothe message body as JSONThe frame's payload. Multi-line values are encoded as multiple data: lines.
streamCELNovars.sseStreamWhich stream to write to. Set it when the route renames the variable, or when the address reached this flow another way.
connectorconnector refNohttpWhich http connector owns the stream. Rarely needed: an address published by a route already names its connector. It applies only to a bare id.
closebooleanNofalseEnd the stream after this frame.
stopbooleanNofalseStop the flow after this frame. Usually paired with close.
ifClosedenumNostopWhat to do when the stream has already ended: stop, ignore, or error.

A block with neither event nor data writes nothing, which is how you close a stream without sending a final frame.

Being wired up wrong (no address resolvable, or a connector that is not configured) always fails the block, whatever ifClosed says. Nobody listening (the client hung up, the stream expired, or it lives on another replica) is decided by ifClosed.

Authenticating a browser stream. EventSource cannot set request headers, and jwt-validate reads the token from one, so putting it in front of an SSE route covers non-browser callers only: curl, server-to-server clients, and fetch-based SSE libraries, which can all send Authorization.

For a browser, authenticate the stream with a same-origin session cookie, marked HttpOnly, Secure, and SameSite. EventSource sends cookies on same-origin requests by itself; cross-origin it needs withCredentials: true and the connector's cors.allowCredentials. Check the session at your edge (proxy or gateway), or copy the Cookie header into a variable with the source's headers setting and reject in a filter ahead of the stream.

Do not put the token in the query string: URLs reach browser history, Referer headers, and proxy and access logs, and a long-lived stream keeps the credential there for as long as it stays open.

Example

Adapted from samples/http-sse.yaml:

flows:
  - name: progress
    source:
      connector: api
      type: http
      settings:
        path: /progress
        sse:
          enabled: true
          finalEvent: true
          finalEventName: done
    process:
      - type: sse-event
        name: accepted
        settings:
          event: status
          data: '"started"'

      - type: sse-event
        name: halfway
        settings:
          event: progress
          data: '{"percent": 50}'

      # The final message is sent as `event: done` by finalEvent, then the
      # stream closes.
      - type: set-payload
        settings:
          value: '{"percent": 100}'
    error:
      # The response is already a committed 200, so failures are told as frames.
      - type: sse-event
        settings:
          event: error
          data: 'vars.error.message'
          close: true
curl -N localhost:8080/progress

http-client connector

The http-client connector (label: HTTP Client) owns a configured client for calling external HTTP APIs: base URL, authentication, default headers, a request timeout, retry on 429, and an opt-in in-memory response cache for GETs. The rest block references it by name.

Provides a source: no, it is outbound only. Blocks that bind to it: rest and rest-dynamic.

Settings

SettingTypeRequiredDefaultDescription
baseURLstringYesnonePrepended to each request path. Must be absolute (scheme and host).
timeoutdurationNo30sBounds each request.
headersmapNounsetApplied to every request unless already set by the block.
maxResponseBytesintNo1048576Response body size cap.
authobjectNononeAuthentication applied to every request (below).
retryobjectNounsetRetry policy for rate-limited (429) responses (below).
poolobjectNounsetConnection pool sizing for outbound requests (below).
cacheobjectNodisabledIn-memory response cache for GET requests (below). Not exposed in the editor schema; configure it in YAML.

Authentication (auth)

SettingTypeRequiredDefaultDescription
typeenum: bearer | basic | oauth2 | gcpNononeAuthentication scheme.
tokenstringWith type: bearernoneToken sent as Authorization: Bearer.
usernamestringWith type: basicnoneBasic auth username.
passwordstringNononeBasic auth password.
tokenURLstringWith type: oauth2noneOAuth2 token endpoint (client-credentials grant).
clientIDstringWith type: oauth2noneOAuth2 client identifier.
clientSecretstringWith type: oauth2noneOAuth2 client secret.
scopesstring[]NounsetRequested OAuth2 scopes.
gcpTokenenum: identity | accessNoidentityWhich token the GCP metadata server mints.
gcpAudiencestringNothe connector's base URL (origin)Audience for a GCP identity token.
gcpScopesstring[]Nothe service account's own scopesScopes for a GCP access token.

For oauth2, the connector mints and refreshes a bearer token via the client-credentials grant and applies it to each request. A request that already carries an Authorization header is never overwritten.

GCP workload identity (type: gcp)

On Cloud Run, GCE, or GKE, type: gcp authenticates as the service account the process runs as. The token comes from the instance metadata server, so nothing secret is configured and nothing is stored.

identity (the default) is an OIDC token stamped with an audience: what one Cloud Run service presents to another, and what an IAP-protected endpoint expects. gcpAudience defaults to the connector's baseURL (origin only), which is what the receiving service validates against, so calling another service usually needs no more than type: gcp. access is an OAuth token carrying scopes, for calling a Google API (Storage, Pub/Sub, BigQuery) directly. With no gcpScopes the metadata server returns the service account's own, which on Cloud Run is cloud-platform.

Tokens are cached until shortly before they expire and refreshed on demand. Nothing is written to the secret store.

type: gcp only works where a metadata server exists. Off Google Cloud the first request fails with an error saying so. Drive the scheme from an environment variable (type: ${API_AUTH}, empty locally and gcp in production) rather than expecting a local fallback; see Add authentication.

Retry (retry)

When an upstream returns 429 Too Many Requests, the connector honors the Retry-After header when present, otherwise backs off exponentially. Each wait is capped at maxBackoff.

SettingTypeRequiredDefaultDescription
maxAttemptsintNo3Total attempts including the first; <= 1 disables retrying.
maxBackoffdurationNo30sUpper bound on each wait between attempts (also caps a large Retry-After).

Connection pool (pool)

The connector keeps a pool of idle connections so outbound requests reuse them instead of dialling and re-handshaking every time. Size it against the flow's workers: workers: 512 asks for up to 512 concurrent outbound requests, so set maxIdleConnsPerHost to at least the workers of the busiest flow calling this connector. Below that, the excess is closed and re-dialled every round.

SettingTypeRequiredDefaultDescription
maxIdleConnsintNo100Idle connections kept across all hosts.
maxIdleConnsPerHostintNo100Idle connections kept per host. This is the one that matters, since a connector talks to a single base URL.
idleConnTimeoutdurationNo90sHow long an idle connection is kept before being closed.
disableKeepAlivesbooleanNofalseDisable connection reuse entirely, one connection per request. For an upstream that mishandles persistent connections.

Since 0.5.0 the notion and slack connectors pool too, at these same defaults.

Keep maxResponseBytes in mind when you size the pool: a connection returns to the pool only once its response body is read to the end. The connector drains what the cap hid, but only up to 4 KiB; past that the connection is dropped instead. Set maxResponseBytes above the responses you expect.

Cache (cache)

SettingTypeRequiredDefaultDescription
enabledbooleanNofalseEnable the GET response cache.
ttldurationNo60sHow long a cached response is served.
maxEntriesintNo256Cache size cap.

rest block

REST Call makes an HTTP request through an http-client connector. Method and path are static; query parameters, headers, and the request body are CEL expressions evaluated per message. The response is folded into the message body: JSON parses into body, anything else becomes raw content carrying the response's Content-Type, and an empty response leaves body null.

SettingTypeRequiredDefaultDescription
connectorstringYesnoneName of the http-client connector to use.
methodenum: GET | POST | PUT | PATCH | DELETE | HEADNoGETHTTP method.
pathstringNounsetPath appended to the connector base URL.
querymap of expressionNounsetQuery params; each value is a CEL expression.
headersmap of expressionNounsetRequest headers; each value is a CEL expression. Setting Authorization here sends a per-request credential, see Sending a per-caller credential.
bodyexpressionNounsetCEL expression for the request body. A string result is sent verbatim; any other value is JSON-encoded (with Content-Type: application/json unless a header overrides it). With bodyType: multipart it must evaluate to a parts map instead.
bodyTypeenum: raw | multipartNorawHow the body is sent. raw sends the evaluated body as-is. multipart treats it as a parts map and renders a multipart/form-data body, see Sending multipart.
failOnErrorbooleanNotrueTurn a 400+ status into a flow error.
statusVarstringNostatusCodeVariable to store the response status code in.

Sending a per-caller credential

The connector's auth describes the deployment's own credential. A flow acting on behalf of its caller carries a different one that arrives with the message: a token relayed from the inbound request, or one an earlier block exchanged or minted.

Set the Authorization header on either block. rest takes it as one entry in its headers map; rest-dynamic renders it with the rest of the map:

flows:
  - name: proxy
    source:
      connector: api
      type: http
      settings:
        path: /me
        headers: [Authorization]      # arrives as vars.Authorization
    process:
      - type: rest
        settings:
          connector: upstream
          method: GET
          path: /v1/me
          headers:
            Authorization: 'vars["Authorization"]'

The value is the whole header, scheme included, so relaying an inbound one needs no change; a bare token needs its scheme written in, as '"Bearer " + vars.token'.

A block credential and a connector credential do not contend: the connector applies its own only when the request does not already carry an Authorization.

Sending the caller's credential upstream lets that upstream use it for anything the credential permits. Send it only to an upstream you trust with it, and prefer an exchange for a narrower token across trust domains. On rest-dynamic, pathPrefix and allowMethods bound what a credential can be spent on; the connector's baseURL bounds where it can be sent at all.

Sending multipart

Set bodyType: multipart and let body evaluate to a parts map: the same shape body.parts holds on an inbound upload, and the one multipart() and addPart build.

- type: rest
  settings:
    connector: upstream
    method: POST
    path: /v1/media
    bodyType: multipart
    body: |
      multipart()
        .addPart("caption", body.caption)
        .addPart("avatar", body.parts.avatar)

The block generates the boundary per request and sets Content-Type itself, overriding any Content-Type set in headers.

A scalar part value is shorthand for a text field. An object may name only the keys it needs; encoding defaults to text:

    body: |
      multipart().addPart("report", {
        "data": vars.csv,
        "filename": "report.csv",
        "contentType": "text/csv"
      })

A decoded part is already the shape addPart takes, so forwarding an upload keeps its filename and content type. Forward one untouched:

    body: 'body.parts'

Or augment it on the way through:

    body: 'body.parts.addPart("source", "octo")'

A part name, filename, or content type containing a carriage return or line feed is rejected when the body is rendered, so names chosen by an uploader cannot smuggle extra parts into the upstream request.

Example

Adapted from samples/weather.yaml, a scheduled GET against a public API, cached for 60 seconds:

service:
  name: weather

connectors:
  - name: open-meteo
    type: http-client
    settings:
      baseURL: https://api.open-meteo.com
      timeout: 10s
      cache:
        enabled: true
        ttl: 60s
  - name: ticker
    type: cron

flows:
  - name: weather
    source:
      connector: ticker
      type: cron
      settings:
        schedule: "@every 30s"
    process:
      - type: rest
        name: fetch-forecast
        settings:
          connector: open-meteo
          method: GET
          path: /v1/forecast
          query:                       # values are CEL expressions
            latitude: '"52.52"'
            longitude: '"13.41"'
            current: '"temperature_2m"'
      - type: log
        settings:
          message: '"current temperature: " + string(body.current.temperature_2m)'

Keep credentials out of the flow file: declare them in the env section and reference them as ${API_TOKEN}, see environment and config.

rest-dynamic block

Dynamic REST Call is the same request with the method, path, query, headers and body all built from CEL expressions per message. Use rest when the flow calls a known endpoint and only the values vary; use this one when the endpoint itself is data, such as a flow relaying a request it was handed, or an agent choosing a route from an API description.

Query and headers differ in shape from rest. Each is a single expression evaluated to a whole map, not a map of expressions. Values are rendered like every other expression result: a string verbatim, anything else as compact JSON.

SettingTypeRequiredDefaultDescription
connectorstringYesnoneName of the http-client connector to use.
methodexpressionYesnoneCEL expression for the HTTP method. Rendered, upper-cased, and checked against GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS.
pathexpressionYesnoneCEL expression for the path, resolved against the connector's base URL.
queryexpressionNounsetCEL expression evaluating to a map of query parameters.
headersexpressionNounsetCEL expression evaluating to a map of request headers, Authorization included, see Sending a per-caller credential.
bodyexpressionNounsetCEL expression for the request body, as in rest.
bodyTypeenum: raw | multipartNorawHow the body is sent, as in rest.
allowMethodslist of stringNounsetMethods this block may issue. Empty allows any of the standard set.
pathPrefixstringNounsetPath prefix this block may call under, matched on segment boundaries. Empty allows the whole base URL.
failOnErrorbooleanNotrueTurn a 400+ status into a flow error.
statusVarstringNostatusCodeVariable to store the response status code in.

What a rendered request may not be

A rendered path is data, so three things are checked before the request is made, and a failure makes no request at all:

  • The method must be one of the seven above.
  • The path must be a relative reference. A full URL is refused rather than silently rewritten. The connector resolves the request against its own base URL and takes only the path, query and fragment, so no expression here can reach another host.
  • The path may not contain a .. segment, which would otherwise escape a base URL carrying a prefix like /v1. The check runs on the parsed path, so %2e%2e is refused with the literal form, and a .. inside a query value is left alone.

allowMethods and pathPrefix are the restriction you configure on top. Both are empty by default, and an allowMethods entry that is not an HTTP method fails at startup. pathPrefix matches on segment boundaries: /integrations admits /integrations and /integrations/abc, but not /integrations-internal.

The connector's baseURL is the security boundary: nothing this block evaluates can change the host it calls. Everything under that host is reachable unless you set pathPrefix. Point the connector at the narrowest base URL that does the job.

Example

Relaying a request described by the message, the shape an agent tool takes:

- type: rest-dynamic
  name: call-api
  settings:
    connector: octo
    method: body.method
    path: body.path
    query: 'has(body.query) ? body.query : {}'
    body: 'has(body.payload) ? body.payload : null'
    # Report the status as data rather than failing the flow, so the caller can
    # act on a 404 instead of the message ending.
    failOnError: false

On this page