HAProxy config reload fails due to invalid function call format

Problem – HAProxy reload fails with “invalid function call format”

During automated deployments in a GitLab CI pipeline the HAProxy service aborts its reload step. The CI job reports a validation error such as:

ERROR: configuration file contains an invalid function call format at line 42
parse error: unexpected token ‘(‘ in fetch expression
Invalid fetch/convert syntax: %[var(…] – missing closing bracket or parenthesis

The failure is reproducible only when the configuration is generated from templates that interpolate environment variables and service‑discovery data. Manual edits of the same file (or a hand‑crafted static config) reload without issue.

Root Cause – Mis‑formatted fetch/converter expressions

HAProxy’s expression language requires a strict %[ ... ] syntax. Inside the brackets a fetch (e.g. var(req.user)) may be followed by zero or more converters (e.g. str()). The official Configuration Syntax documentation states:

  • Fetches are written as fetch_name(arg1,arg2) – parentheses are mandatory.
  • Converters are appended with their own parentheses, e.g. str(%[var(req.user)]).
  • The entire expression must be enclosed in %[ … ] with matching brackets.

When a templating engine (Jinja2, Go‑text/template, etc.) expands variables, missing or mismatched parentheses/brackets are introduced. Typical patterns observed in real incidents include:

Incorrect snippet Reason
http-request set-var(req.user) str(%[var(req.user)])
Correct – both parentheses present.
http-request set-var(req.user) str(%[var(req.user])
Missing closing parenthesis after var(req.user).
acl is_api path_beg %[var(env.API_PATH)]
Correct when API_PATH is defined.
acl is_api path_beg %[]
Environment substitution produced an empty string, leaving an empty fetch.
use_backend %[var(req.backend)]
Correct.
use_backend %[var(req.backend]
Missing closing bracket.

HAProxy’s runtime API (set-var, use_backend) validates the same syntax at reload time; any deviation triggers the “invalid function call format” error.

Debug – Investigating the malformed configuration

  1. Run HAProxy validation locally. In the CI job add a step:
haproxy -c -f generated/haproxy.cfg

Typical output:

[ERR] line 42: invalid function call format – missing closing parenthesis in fetch expression
[INFO] configuration file contains 1 error(s)

  • Inspect the generated file at the reported line. Use sed -n '40,44p' generated/haproxy.cfg to view surrounding context.
  • # line 40
    http-request set-var(req.user) str(%[var(req.user])   # ← malformed
    # line 41
    acl is_api path_beg %[]                         # ← empty fetch
    # line 42
    use_backend %[var(req.backend]
  • Check the templating logs. Most CI pipelines render the template with a debug flag. Example for Jinja2:
  • jinja2 --debug-template templates/haproxy.cfg.j2 -D API_PATH=$API_PATH -o generated/haproxy.cfg

    Look for warnings about “undefined variable” or “missing closing tag”.

  • Validate environment variable substitution. Print the raw values used by the pipeline:
  • echo "API_PATH=$API_PATH"

    If API_PATH is empty, the resulting ACL becomes %[], which HAProxy cannot parse.

  • Capture the exact error string from HAProxy’s management socket. When using systemctl reload haproxy, the service manager logs the error:
  • systemd[1]: haproxy.service: Reloading.
    systemd[1]: haproxy.service: Failed to reload configuration: invalid function call format at line 42

    Solution – Ensure well‑formed fetch/converter syntax in generated configs

    1. Guard template expressions with defaults

    For Jinja2, use the default filter to guarantee a non‑empty string:

    {% set api_path = env.API_PATH | default('/api') %}
    acl is_api path_beg %[var(env.API_PATH)]

    Resulting snippet (after rendering):

    acl is_api path_beg %[var(env.API_PATH)]

    2. Enforce matching parentheses/brackets with a linter

    Add a CI step that runs a simple regex‑based check:

    #!/usr/bin/env bash
    cfg=generated/haproxy.cfg
    if grep -P '(%\[[^\]]*$|%[^\[]*$|[^)]\s*%|\[[^\]]*$' "$cfg" >/dev/null; then
      echo "⚠️  Detected possible mismatched %[ ... ] expression"
      exit 1
    fi

    3. Refactor complex expressions into variables

    Instead of embedding long fetch chains directly, store them in a set-var earlier in the frontend and reference the variable later:

    # Before (error‑prone)
    http-request set-var(req.user) str(%[var(req.user)])
    
    # After (clear separation)
    http-request set-var(req.user) %[var(req.user)]
    http-request set-var(req.user.str) str(%[var(req.user)])

    4. Update the CI template to quote dynamic values

    When inserting raw strings into HAProxy directives, wrap them in double quotes to prevent the templating engine from stripping required characters:

    use_backend "{{ backend_name | default('default_backend') }}"

    5. Example – Fixed configuration

    Below is a before/after comparison of a problematic snippet and its corrected version.

    Before (fails) After (passes)
    # Generated by CI – missing parentheses
    http-request set-var(req.user) str(%[var(req.user])
    acl is_api path_beg %[]
    use_backend %[var(req.backend]
    # Fixed – all fetches properly closed
    http-request set-var(req.user) str(%[var(req.user)])
    acl is_api path_beg %[var(env.API_PATH)]   # env.API_PATH defaults to /api
    use_backend %[var(req.backend)]

    Verify – Confirm that HAProxy reloads cleanly

    1. Run the validation command again:
    haproxy -c -f generated/haproxy.cfg

    Expected output:

    [INFO] Configuration file is valid
    [INFO] 0 errors found

    1. Trigger a reload in the pipeline (or locally):
    systemctl reload haproxy

    Check the service status:

    systemctl status haproxy

    Expected snippet:

    haproxy.service – HAProxy Load Balancer
    Loaded: loaded (/etc/systemd/system/haproxy.service; enabled)
    Active: active (running) since …
    Main PID: 1234 (haproxy)

    Reloaded: Thu 2026-06-22 14:03:12 UTC

    1. Inspect runtime logs for residual warnings:
    journalctl -u haproxy -b | grep -i warning

    No “invalid function call format” entries should appear.

    Prevent – Guardrails for future deployments

    • Schema validation step. Incorporate haproxy -c into every merge request pipeline before merging to main.
    • Template unit tests. Render templates with a matrix of environment variable values (including empty strings) and assert that the resulting file matches a regex pattern for %\[[^\]]+\].
    • Static analysis. Use tools like haproxy-lint (or a custom script) to detect unmatched parentheses/brackets.
    • Explicit defaults. Always provide a fallback value for every environment‑derived variable used in HAProxy expressions.
    • Version pinning. The syntax rules changed subtly between HAProxy 2.7 and 2.8; lock the binary version in CI to avoid surprise regressions.

    Related Topic Hub: Distributed Systems Troubleshooting Hub

    FAQ

    1. Why does the reload succeed locally but fail in CI? CI often runs the templating step with a different set of environment variables. Missing variables become empty strings, producing malformed %[ ... ] expressions that pass local checks because the local file is hand‑edited.
    2. Can I ignore the error and force a reload? No. HAProxy aborts the reload if the configuration does not parse. Forcing a reload would leave the previous configuration active, potentially serving stale routing rules.
    3. Is there a way to see which fetch caused the error without line numbers? Use haproxy -c -f cfg -V (verbose mode). The output includes a tokenized view of each expression, helping locate the offending fetch.
    4. Do converters like str() require parentheses even when no arguments are needed? Yes. The syntax is converter(arg); omitting the parentheses makes the token appear as an unknown fetch, triggering “invalid function call format”.
    5. How do I handle dynamic backend selection safely? Store the backend name in a request‑scoped variable first, then use use_backend %[var(req.backend)]. Ensure the variable is always set (e.g., default to a fallback backend) before the use_backend line executes.