Director
The Director pattern can be used to implement workflow functions that coordinate other functions through sequencing, branching, or parallel execution.
This page builds a deliberately small example: two functions each return a JSON object, and a director combines them into one response.
[ Client ]
│
▼
┌──────────────────────┐
│ director │
└──────────┬───────────┘
├── 1. invoke ──► [ function-a ] ──► {"a": 1} ──┐
├── 2. invoke ──► [ function-b ] ──► {"b": 2} ──┤
│ │
├────────────── merge results ◄─────────────────┘
│
▼
[ Response: {"a": 1, "b": 2} ]
This simple workflow highlights several properties of the Director pattern:
- Single endpoint: the caller only invokes
directorand receives one response. - Sequencing: the director invokes
function-a, followed byfunction-b. - Independent functions: each function is deployed and scaled separately, and could be written in a different language.
- Composition and error handling: the director validates and merges both results, or returns an error when something fails.
Create the functions¶
Choose a language and scaffold all three functions in one stack.yaml file:
faas-cli template store pull golang-middleware
faas-cli new --lang golang-middleware director \
--prefix ttl.sh/openfaas-examples
faas-cli new --lang golang-middleware function-a \
--append stack.yaml --prefix ttl.sh/openfaas-examples
faas-cli new --lang golang-middleware function-b \
--append stack.yaml --prefix ttl.sh/openfaas-examples
faas-cli template store pull python3-http
faas-cli new --lang python3-http director \
--prefix ttl.sh/openfaas-examples
faas-cli new --lang python3-http function-a \
--append stack.yaml --prefix ttl.sh/openfaas-examples
faas-cli new --lang python3-http function-b \
--append stack.yaml --prefix ttl.sh/openfaas-examples
faas-cli template store pull node24
faas-cli new --lang node24 director \
--prefix ttl.sh/openfaas-examples
faas-cli new --lang node24 function-a \
--append stack.yaml --prefix ttl.sh/openfaas-examples
faas-cli new --lang node24 function-b \
--append stack.yaml --prefix ttl.sh/openfaas-examples
The example uses the public [ttl.sh] registry. Replace the prefix with your own registry for production use.
The full source code and stack.yaml files are available on GitHub for
Go,
Python,
and Node.js.
Implement the two functions¶
Create two simple functions that each return a JSON payload. Since neither
function needs input, the completed workflow is invoked with a GET request.
function-a/handler.go:
package function
import (
"context"
"encoding/json"
"net/http"
)
func Handle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"a": 1})
}
function-b/handler.go:
package function
import (
"encoding/json"
"net/http"
)
func Handle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"b": 2})
}
function-a/handler.py:
def handle(event, context):
return {"statusCode": 200, "body": {"a": 1}}
function-b/handler.py:
def handle(event, context):
return {"statusCode": 200, "body": {"b": 2}}
function-a/handler.js:
'use strict'
module.exports = async (event, context) => context
.status(200)
.headers({ 'Content-Type': 'application/json' })
.succeed({ a: 1 })
function-b/handler.js:
'use strict'
module.exports = async (event, context) => context
.status(200)
.headers({ 'Content-Type': 'application/json' })
.succeed({ b: 2 })
Implement the director¶
The director invokes function-a and function-b in sequence. It decodes each
response as a JSON object and copies its fields into the combined response.
director/handler.go:
package function
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
const defaultStageTimeout = 5 * time.Second
func Handle(w http.ResponseWriter, r *http.Request) {
gateway := os.Getenv("gateway_url")
if gateway == "" {
gateway = "http://gateway.openfaas:8080"
}
gateway = strings.TrimRight(gateway, "/")
timeout := stageTimeout()
// Invoke function-a and decode its JSON response.
ctxA, cancelA := context.WithTimeout(r.Context(), timeout)
defer cancelA()
reqA, err := http.NewRequestWithContext(
ctxA,
http.MethodGet,
gateway+"/function/function-a",
nil,
)
if err != nil {
http.Error(w, fmt.Sprintf("function-a: %s", err), http.StatusBadGateway)
return
}
resA, err := http.DefaultClient.Do(reqA)
if err != nil {
http.Error(w, fmt.Sprintf("function-a: %s", err), http.StatusBadGateway)
return
}
defer resA.Body.Close()
if resA.StatusCode != http.StatusOK {
status := resA.StatusCode
http.Error(w, fmt.Sprintf("function-a: returned %s", resA.Status), status)
return
}
combined := map[string]any{}
if err := json.NewDecoder(resA.Body).Decode(&combined); err != nil {
http.Error(
w,
fmt.Sprintf("function-a: returned invalid JSON: %s", err),
http.StatusBadGateway,
)
return
}
// Invoke function-b after function-a has completed.
ctxB, cancelB := context.WithTimeout(r.Context(), timeout)
defer cancelB()
reqB, err := http.NewRequestWithContext(
ctxB,
http.MethodGet,
gateway+"/function/function-b",
nil,
)
if err != nil {
http.Error(w, fmt.Sprintf("function-b: %s", err), http.StatusBadGateway)
return
}
resB, err := http.DefaultClient.Do(reqB)
if err != nil {
http.Error(w, fmt.Sprintf("function-b: %s", err), http.StatusBadGateway)
return
}
defer resB.Body.Close()
if resB.StatusCode != http.StatusOK {
status := resB.StatusCode
http.Error(w, fmt.Sprintf("function-b: returned %s", resB.Status), status)
return
}
functionB := map[string]any{}
if err := json.NewDecoder(resB.Body).Decode(&functionB); err != nil {
http.Error(
w,
fmt.Sprintf("function-b: returned invalid JSON: %s", err),
http.StatusBadGateway,
)
return
}
// Merge function-b into function-a and return the combined object.
for key, value := range functionB {
combined[key] = value
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(combined)
}
func stageTimeout() time.Duration {
if value := os.Getenv("stage_timeout"); value != "" {
if timeout, err := time.ParseDuration(value); err == nil && timeout > 0 {
return timeout
}
}
return defaultStageTimeout
}
director/handler.py:
import os
import requests
GATEWAY_URL = os.getenv(
"gateway_url", "http://gateway.openfaas:8080"
).rstrip("/")
STAGE_TIMEOUT = float(os.getenv("stage_timeout", "5"))
def handle(event, context):
# Invoke function-a and decode its JSON response.
try:
response_a = requests.get(
f"{GATEWAY_URL}/function/function-a",
timeout=STAGE_TIMEOUT,
)
except requests.RequestException as err:
return error(502, f"function-a: {err}")
if response_a.status_code != 200:
return error(response_a.status_code, f"function-a: {response_a.text}")
try:
combined = response_a.json()
except ValueError as err:
return error(502, f"function-a returned invalid JSON: {err}")
if not isinstance(combined, dict):
return error(502, "function-a did not return a JSON object")
# Invoke function-b after function-a has completed.
try:
response_b = requests.get(
f"{GATEWAY_URL}/function/function-b",
timeout=STAGE_TIMEOUT,
)
except requests.RequestException as err:
return error(502, f"function-b: {err}")
if response_b.status_code != 200:
return error(response_b.status_code, f"function-b: {response_b.text}")
try:
function_b = response_b.json()
except ValueError as err:
return error(502, f"function-b returned invalid JSON: {err}")
if not isinstance(function_b, dict):
return error(502, "function-b did not return a JSON object")
# Merge function-b into function-a and return the combined object.
combined.update(function_b)
return {"statusCode": 200, "body": combined}
def error(status_code, message):
return {"statusCode": status_code, "body": message.strip()}
Add requests to director/requirements.txt.
director/handler.js:
'use strict'
const gatewayURL = process.env.gateway_url ||
'http://gateway.openfaas:8080'
const stageTimeout = Number(process.env.stage_timeout || '5') * 1000
module.exports = async (event, context) => {
const gateway = gatewayURL.replace(/\/$/, '')
// Invoke function-a and decode its JSON response.
let responseA
try {
responseA = await fetch(`${gateway}/function/function-a`, {
method: 'GET',
signal: AbortSignal.timeout(stageTimeout)
})
} catch (error) {
return fail(context, 502, `function-a: ${error.message}`)
}
if (responseA.status !== 200) {
return fail(context, responseA.status, `function-a: ${await responseA.text()}`)
}
let combined
try {
combined = await responseA.json()
} catch (error) {
return fail(context, 502, `function-a returned invalid JSON: ${error.message}`)
}
if (!combined || Array.isArray(combined) || typeof combined !== 'object') {
return fail(context, 502, 'function-a did not return a JSON object')
}
// Invoke function-b after function-a has completed.
let responseB
try {
responseB = await fetch(`${gateway}/function/function-b`, {
method: 'GET',
signal: AbortSignal.timeout(stageTimeout)
})
} catch (error) {
return fail(context, 502, `function-b: ${error.message}`)
}
if (responseB.status !== 200) {
return fail(context, responseB.status, `function-b: ${await responseB.text()}`)
}
let functionB
try {
functionB = await responseB.json()
} catch (error) {
return fail(context, 502, `function-b returned invalid JSON: ${error.message}`)
}
if (!functionB || Array.isArray(functionB) || typeof functionB !== 'object') {
return fail(context, 502, 'function-b did not return a JSON object')
}
// Merge function-b into function-a and return the combined object.
Object.assign(combined, functionB)
return context
.status(200)
.headers({ 'Content-Type': 'application/json' })
.succeed(combined)
}
function fail (context, status, message) {
return context
.status(status)
.headers({ 'Content-Type': 'text/plain' })
.succeed(message.trim())
}
The director calls each function through the gateway, so the functions can be
written in different languages and scaled independently. A transport error or
invalid JSON response returns 502 Bad Gateway. A non-200 response is
attributed to the function that returned it and its status is passed through to
the caller.
Configure the director's timeouts in stack.yaml:
functions:
director:
lang: golang-middleware
handler: ./director
image: ttl.sh/openfaas-examples/director:latest
environment:
stage_timeout: 5s
exec_timeout: 15s
read_timeout: 16s
write_timeout: 16s
functions:
director:
lang: python3-http
handler: ./director
image: ttl.sh/openfaas-examples/director:latest
environment:
stage_timeout: "5"
exec_timeout: 15s
read_timeout: 16s
write_timeout: 16s
functions:
director:
lang: node24
handler: ./director
image: ttl.sh/openfaas-examples/director:latest
environment:
stage_timeout: "5"
exec_timeout: 15s
read_timeout: 16s
write_timeout: 16s
Configure timeouts¶
A director stays active while it waits for the functions it invokes, so each call should have a timeout.
This example uses the custom stage_timeout
environment variable, the other variables below are OpenFaaS watchdog settings.
| Setting | Scope |
|---|---|
stage_timeout |
Custom HTTP client timeout for each downstream call |
exec_timeout |
Maximum duration of the complete director invocation |
read_timeout, write_timeout |
Watchdog timeouts, set slightly longer than exec_timeout |
This director calls the functions sequentially, so its expected duration is the sum of both calls plus a small amount of overhead. Configure the director's timeout for that complete path, not for a single function.
The gateway's upstream_timeout must be at least as long as the director's
exec_timeout. Adjust the example values for your own functions and see
extended timeouts for the complete
configuration.
Deploy and invoke¶
Build, push, and deploy all three functions:
faas-cli up --tag=sha
Invoke the director:
curl -s http://127.0.0.1:8080/function/director | jq
The director returns the union of the two responses:
{
"a": 1,
"b": 2
}
Workflow considerations¶
- Invoke dependent functions in sequence. Independent functions can run in parallel to reduce latency, but the director must still wait for every result it needs before continuing.
- The director owns the error policy. Depending on the workflow, it can stop, retry, return a partial result, or save progress for a later invocation.
- If retrying the director could repeat side effects in a function that already completed, make those operations idempotent.
- The functions invoked by a director are deployed independently. Each can use a different language, scale separately, and be updated without moving the workflow logic out of the director.
- For a long-running workflow, the director can be invoke asynchronously through
/async-function/directorwith anX-Callback-Url. The director continues to wait for its functions, while the client receives the final result through the callback. See asynchronous functions.