Azure / Azure/static-web-apps

Regression: SWA managed Functions silently cap new deploys at ~39 app.http() registrations (deploy reports success, zero functions register, every /api/* returns 404)

Open
#1,762 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
No language data
Stars
346
Forks
67
PR merge metrics
No merged PRs in 30d

Description

## Summary

Sometime between **2026-06-13** and **2026-06-22**, Azure Static Web Apps' managed Functions runtime introduced a hard registration cap of approximately **39 `app.http()` calls per deploy** when using the Azure Functions v4 programming model (Node) with a single `main.js` entrypoint.

Crossing this cap causes a **silent failure**:

- The GitHub Actions deploy (`Azure/static-web-apps-deploy@v1`) reports `Deployment Complete :)`
- The workflow run shows ✅ success in GitHub Actions
- Oryx logs show `Finished building function app with Oryx`, `Functions Runtime: ~4, node version: 22`
- The SWA portal shows the build as `Ready`
- BUT: every `/api/*` URL on the SWA returns 404 — and not the SPA `index.html` 404 fallback, the **function host's own 404 page** with `Azure Static Web Apps - 404: Not found` and the `appservice.azureedge.net` favicon
- `az staticwebapp environment functions --name --resource-group --environment-name default --query 'length(@)'` returns **0**
- ARM REST `GET /subscriptions//resourceGroups//providers/Microsoft.Web/staticSites//functions?api-version=2024-04-01` returns `{"value": []}`
- ARM REST under `api-version=2023-12-01` likewise: `{"id": null, "nextLink": null, "value": []}`

Previously-deployed environments with >39 functions still work, because they registered before the regression. This is empirically observable on the affected SWA (details below).

Available privately on request: subscription ID, full GitHub repo URL, deploy artifact dumps, full Oryx and Azure Functions runtime logs.

## Affected SWA

Resource details (publicly observable via the custom hostnames):

| Field | Value |
|---|---|
| Resource name | `ltmg-website` |
| Resource group | `LTMG` |
| Subscription ID | available on request (private) |
| Region | Central US |
| SKU | Standard |
| API config | Managed Functions (no `userProvidedFunctionApps`, no `linkedBackends`) |
| `api_location` | `api` |
| Functions programming model | v4 (`api/package.json` → `"main": "main.js"`, `@azure/functions ^4.5.0`) |
| Oryx version (from deploy logs) | `0.2.20260109.4+7d54dd5970dbbf3ae6bdcf4dee57b07816a392ce` |
| Detected Node version | `22.22.0` |
| Functions runtime | `~4` |
| Extension bundle | `Microsoft.Azure.Functions.ExtensionBundle`, `[4.*, 5.0.0)` |
| `userProvidedFunctionApps` | `null` |
| `linkedBackends` | `[]` |
| `backendRegion` | `{ regionName: null, status: null }` |

## Reproduction

A controlled bisection was performed on a test branch (`test/minimal-api`) and a fresh preview environment (`build 9` on the affected SWA), creating a new PR each step so the function host was never reused. Same `staticwebapp.config.json`, same `host.json`, same `api/package.json`. Only the count of `app.http()` calls in `api/main.js` varied.

Each handler was the same one-line stub:

```js
app.http('hN', {
methods: ['GET'],
authLevel: 'anonymous',
route: 'hN',
handler: async () => ({ status: 200, jsonBody: { ok: true } }),
});
```

Result:

| `app.http()` count in `api/main.js` | `az staticwebapp environment functions --query 'length(@)'` | `/api/health` |
|---|---|---|
| 1 | **1** | 200 OK |
| 21 | **21** | 200 OK |
| 31 | **31** | 200 OK |
| 36 | **36** | 200 OK |
| 39 | **39** | 200 OK |
| 40 | **0** | 404 (SWA function host 404 page) |
| 41 | **0** | 404 |
| 67 | **0** | 404 |
| 73 | **0** | 404 |
| 145 (full production code) | **0** | 404 |

Each step was a fresh PR triggering a fresh preview env build to rule out function-host caching. The 39 vs 40 boundary was also reproduced on the production `default` environment after the workaround was rolled back temporarily.

Specifically:

- **39 functions in `main.js` → all 39 registered.** Confirmed via ARM REST `GET .../functions` listing all 39 by name. Each function served real traffic (400/401/etc. based on the handler's input validation).
- **40 functions in `main.js` → zero registered.** ARM REST returns `value: []`. Direct HTTP requests return the SWA's branded 404 page from the function host, not the app's `index.html`. Identical deploy artifact otherwise.

The boundary holds at 39/40 with the simple test handlers above. Production handlers (body parsing, DB connections, blob clients, etc.) do not change the threshold.

## Strongest single piece of evidence: `build 6` vs `build 9`

Two preview environments on the same SWA, deployed from the same GitHub repository, same deploy action version, same secrets, but at different times:

```
$ az staticwebapp environment list --name ltmg-website -g LTMG \
--query '[?name==`6` || name==`9`].{name:name,createdTimeUtc:createdTimeUtc,sourceBranch:sourceBranch,status:status}' -o table

Name CreatedTimeUtc SourceBranch Status
------ -------------------------------- ---------------------- ------
6 2026-06-13T21:23:12.940175+00:00 feat/careers-page Ready
9 2026-06-22T... test/minimal-api Ready
```

```
$ az staticwebapp environment functions --name ltmg-website -g LTMG --environment-name 6 --query 'length(@)'
110

$ az staticwebapp environment functions --name ltmg-website -g LTMG --environment-name 9 --query 'length(@)'
0 # this build deployed 40 functions
```

```
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST \
https://gray-beach-04906f410-6.centralus.7.azurestaticapps.net/api/contact \
-H 'Content-Type: application/json' -d '{}'
400 # handler ran, rejected empty body — confirms function-execution works

$ curl -s -o /dev/null -w '%{http_code}\n' \
https://gray-beach-04906f410-9.centralus.7.azurestaticapps.net/api/health
404 # SWA 404 page; no function host response
```

Same SWA. Same managed Functions runtime. Same deploy mechanism. Same `staticwebapp.config.json` schema. Only **when the build was performed** differs.

## Snippet of the "successful" deploy log (with zero functions registered)

For the regression-affected deploy (40 functions in `main.js`):

```
2026-06-22T02:20:42 Error: Could not detect the language from repo.
2026-06-22T02:20:42 Oryx was unable to determine the build steps. Continuing assuming the assets in this folder are already built.
2026-06-22T02:20:42 Finished building app with Oryx
2026-06-22T02:20:44 Api Directory Location: 'api' was found.
2026-06-22T02:20:44 Starting to build function app with Oryx
2026-06-22T02:20:53 Function Runtime Information. OS: linux, Functions Runtime: ~4, node version: 22
2026-06-22T02:20:53 Finished building function app with Oryx
2026-06-22T02:20:59 Uploading build artifacts.
2026-06-22T02:21:03 Finished Upload. Polling on deployment.
2026-06-22T02:21:36 Deployment Complete :)
```

No warning, no error, no hint that the function host then registered zero of the 40 functions present in the deployed bundle.

## What's NOT the cause (ruled out during diagnosis)

We spent considerable time ruling these out — sharing in case other reporters waste time on the same theories:

1. **Not the Oryx build.** Oryx reports `Finished building function app with Oryx`. The function app directory is copied to `/bin/staticsites/-swa-oryx/api/` and dependencies installed (`api/.oryx_prod_node_modules` → `api/node_modules`). No errors at build phase.
2. **Not the deploy authentication.** The `azure_static_web_apps_api_token` is valid. The deploy step's polling step reports `Finished Upload. Polling on deployment.` then `Deployment Complete :)`. There is **no error surface at any stage** — this is what makes the regression dangerous.
3. **Not Cloudflare cache.** The custom domain is fronted by Cloudflare. We confirmed by hitting the default `*.azurestaticapps.net` host directly: it also returns 404 for every `/api/*`. Cloudflare's `Cf-Cache-Status: DYNAMIC` confirms the response is not cached.
4. **Not function.json conflicts.** We had ~50 v3-style `function.json` files in `api/*` subdirectories alongside the v4 model in `main.js`. Removing all of them (`git rm api/*/function.json`) did not change behavior. The v4 `app.http()` cap holds independent of v3 `function.json` presence.
5. **Not the `responseOverrides 404→/index.html 200` rewrite in `staticwebapp.config.json`.** This was making the 404 look like a 200 with HTML body, hiding the function-host response. Removing the override surfaces the real 404 but does not fix the registration failure. (Recommend other affected customers remove this override first to make diagnosis possible.)
6. **Not workflow file duplication.** Only one `Deploy to Azure Static Web Apps` workflow on `push: main`.
7. **Not Node engine version.** `engines.node` in `api/package.json` is just a warning to Oryx; the runtime version is determined by SWA. Pinning to `~20` produced an `npm warn EBADENGINE` but the runtime kept using Node 22 and the cap remained.
8. **Not `host.json` configuration.** Default `extensionBundle: [4.*, 5.0.0)` was used throughout. Adding `extensions.http.routePrefix: "api"` (a wrong guess on our part) broke things differently — produced URLs at `/api/api/` — but with `routePrefix` removed the original cap behavior returned.
9. **Not `az staticwebapp disconnect`/`reconnect`.** Cycling source control had no effect on the function host's behavior. A duplicate workflow file is auto-created on reconnect and must be cleaned up.
10. **Not an SWA deploy-key issue.** We rotated the deploy key (`az rest .../resetapikey`), updated the GitHub secret, and observed the same registration failure on the next deploy. (Note: rotating the key without updating the GH secret causes a SEPARATE silent failure mode where the deploy upload step fails with `No matching Static Web App was found or the api key was invalid` and reports overall workflow success — this is also worth surfacing as an error.)

## Customer impact

This is a high-severity regression because it presents as a successful deploy:

- **All API endpoints offline.** Every `/api/*` URL returns 404 for the entire time the deploy is live.
- **Webhooks silently dropped.** Stripe billing webhooks (`/api/stripe-billing`), SMS-provider webhooks (`/api/blooio-webhook`), and other vendor webhooks 404 — no DLQ, no alert. Stripe will retry per its policy; SMS providers usually do not.
- **Cron-style callbacks silently dropped.** Internal cleanup, DNS-check, dunning-advance jobs registered with n8n / Logic Apps all 404 silently.
- **No error surface in standard observability.** GitHub Actions: green. Azure Portal SWA blade: Ready. Build status: Ready. Application Insights (if enabled) sees zero function invocations — but customers without App Insights see nothing.
- **Time-to-detect is high.** The customer in this case had ~24 hours of production downtime before root-causing. The marketing site, demo blobs, and login flow all kept working (served from blob / SWA's built-in auth), so visitor traffic was fine. Only the admin tools, client portal, and webhook receivers were dead.
- **Time-to-resolve is high.** Without a clear error, customers chase token rotation, host.json changes, function.json cleanup, disconnect/reconnect, and other rat holes before considering a hard count cap. We spent ~6 hours in those dead ends.

## Workaround (what we shipped on `ltmg-website`)

For other affected customers: consolidate your `app.http()` calls into "router" functions that dispatch internally by URL parameter. We went from 145 registrations to 39 by adding 8 router functions:

| Router | Route template | Endpoints handled |
|---|---|---|
| `portalRouter` | `portal-{op}` | 23 |
| `careersRouter` | `careers-{op}` | 5 |
| `proxyRouter` | `proxy-{op}` | 3 |
| `mcMidflowRouter` | `mc-{op}` | 41 |
| `opsRouter` | `ops-{op}` | 15 |
| `siteRouter` | `site-{op}` | 8 |
| `launchsiteRouter` | `launchsite-{op}` | 7 |
| `catchallRouter` | `{path:regex(^(a\|b\|c)$)}` | 6 |

Code sketch:

```js
// Internal dispatch table; handlers can be inline v4 or v3-style required from disk
const portalHandlers = {
'data': require('./portal-data/index'),
'team': require('./portal-team/index'),
'leads': require('./portal-leads/index'),
// ... 20 more
};

app.http('portalRouter', {
methods: ['GET', 'POST', 'OPTIONS'],
authLevel: 'anonymous',
route: 'portal-{op}',
handler: async (request, context) => {
const h = portalHandlers[request.params.op];
if (!h) return { status: 404, jsonBody: { error: 'Unknown' } };
return wrapLegacy(h)(request, context); // adapter for v3-style handlers
},
});
```

Frontend URLs are unchanged. `staticwebapp.config.json` role-gate rules (`/api/portal-*` → `authenticated`) still apply because the wildcard matches the URL pattern regardless of how the underlying function is implemented.

## Route-precedence findings (currently undocumented for v4 model)

While building the routers above, we hit two distinct route-template behaviors that are not documented in the [Azure Functions HTTP trigger docs](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger):

1. **Single-segment parameterized routes (`route: 'prefix-{op}'`) DO observe literal-vs-parameterized precedence.** A router at `route: 'mc-{op}'` does NOT cannibalize an existing literal `route: 'mc-kanban'` registration. Empirically confirmed across all 7 prefix routers (28+ literal routes coexisting with each `prefix-{op}` parameterized router).
2. **Catchall routes (`route: '{*path}'`) do NOT observe that precedence.** An un-constrained `{*path}` catchall WILL steal literal-route URLs. We confirmed this on `build 12` (commit `7ba0c0d` on the affected SWA): an un-constrained catchall stole `/api/track` and `/api/contact` even though both have literal `app.http()` registrations. The fix was to add a regex constraint: `route: '{path:regex(^(track|contact|...)$)}'`.

Both behaviors are reasonable, but the asymmetry between `{op}` and `{*path}` is not documented and surprised us during the bisection. Recommend documenting these in the v4-model HTTP trigger reference.

## Asks

1. **Confirm the regression** and an internal repro on Microsoft's side.
2. **Time-to-fix estimate** so customers can decide between the router workaround (intrusive refactor) and waiting.
3. **Surface a clear deploy-time error** when the cap is hit. Currently the deploy reports success with zero registered functions. At minimum:
- A WARN-level log in the GitHub Actions deploy step like `WARN: functions in deployment exceed registration capacity; registered`.
- A non-success exit code from the deploy action when the function-host reports zero registrations.
- Optionally: a portal banner on the SWA blade when functions are present in the deploy artifact but zero are registered.
4. **Document the route-precedence behavior** of `{op}` vs `{*path}` vs literal routes in the v4 model HTTP trigger reference.
5. **(Bonus) Document the corollary deploy-token-rotation silent failure** referenced in "ruled out" #10 above.

Happy to provide additional logs, repro PR, or join a call. Resource subscription ID and other private details available on request via this issue or a private channel.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the controlled bisection in api/main.js, comparing the 39- and 40-registration deployments, and inspect api/package.json, host.json, and staticwebapp.config.json for differences. Reproduce the behavior with the Azure Static Web Apps deploy action, then verify the function listing and /api/health response. Done means the registration failure is explained and deployments either register the functions or expose a clear error.

Written by the indexing model from the issue text.

Assessment

Tech stack
azure, github-actions, javascript, node.js
Domain
api, backend, ci-cd, cloud
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.