Situs AppFolio budget: why it refuses, and why it was never pinned

Read-only diagnosis of the durable AppFolio request budget for database situsgroup. Every number was re-derived from production.

2026-10-23 · dock situs-appfolio-budget, row diagnose-budget-pause · all times UTC · environment: propflow-prod (DynamoDB) and /aws/lambda/propflow-appfolio-sync-prod (CloudWatch) · read-only, no prod writes

The headline. The budget was never “pinned empty since 2026-09-29 16:00Z.” That timestamp is throttleCountSince — the birthday of the 429 counter on this account, stamped by the first 429 it ever recorded. That first 429 was real and it was a storm (772 in one hour), but it was over within two hours, and the account then issued zero AppFolio Reports API calls for the next fifteen days because staff had paused its sync. The pressure starving Situs maintenance today began 2026-10-14 20:05Z, when that pause lifted. It is not a stuck pause and not a bucket that fails to refill: it is a single 6-token/15s bucket with no ordering and no per-caller share, refusing roughly 200 calls a day while running at 19% average utilisation.

⚠ Correction, 2026-10-23 — the :00 mechanism is withdrawn

What was wrong. The first revision of this page said the refusals were caused by an ~78-request hourly batch arriving “within seconds of each hour boundary,” and recommended spreading that batch as the cheapest fix. Both are gone from the page — deleted, not footnoted, because a caveat under a wrong causal claim leaves the wrong claim doing the work.

What replaced it, and by what measurement. A sibling lane on this dock measured dispatch directly rather than inferring it from report volume: 7,135 dispatch lines over 24h, folded onto minute-of-hour.

cadence classdispatches / 24hminute-of-hour
bookkeeping28:21 (27), :22 (1) — two distinct minutes; ten consecutive hours fire at HH:21:26
answer_live332:01, :06, :11, :26, :41… — 30 minutes
act_now1,631all 60, flat (~27 each)

Nothing dispatches at :00. The hourly batch is already 21 minutes off the boundary, so whatever clusters at :00 — if anything does — the batch is not its cause.

Now open, and nobody should guess at it. Whether refusals cluster at :00–:01 at all is unresolved. The only reading is mine, and it is a 34-event sample over four hours (20 at :00, 2 at :01, 7 at :11, 3 at :16, 1 each at :36 and :41) that nobody has re-derived. It may be real with a cause other than the batch, or it may be small-sample noise. The prod-verify-24h lane is already reading the refusal population for its own baseline and will emit a minute-of-hour histogram as a by-product — that is where the answer comes from, and this page deliberately does not re-derive it, because two lanes computing one number is how one fact becomes two that disagree.

What this does NOT touch. Everything else here is measured per report, per row or per log line, and none of it depends on a clock story:

1 · Is the refusal rate real, and what is it?

It is real, and it is two separate numbers that the write-up merges into one. The budget row carries a counter for AppFolio refusing us; it carries no counter at all for our own budget refusing our own callers — and the second one is what starves work orders.

1a · AppFolio’s own 429s: 6,077, cumulative over 23.4 days — since-onset, not per-24h

This is the question the prerequisite PR’s reviewer flagged, and the answer is unambiguous: since-onset. throttleCount is documented in durable-account-budget.ts as cumulative and never reset, and throttleCountSince names the window it covers.

aws dynamodb get-item --table-name propflow-prod --consistent-read \
  --key '{"PK":{"S":"CONFIG"},"SK":{"S":"APPFOLIO_BUDGET#appfolio#situsgroup#86fe"}}'
FieldValue at 2026-10-23 01:21:50ZWhat it means
throttleCount6,077429s from AppFolio, cumulative, never reset
throttleCountSince1790697652125 = 2026-09-29T16:00:52.125ZThe window the count covers. This is the “onset” in the audit.
tokens5.0 of capacity 6Not empty
pausedUntil2026-10-23T01:16:36Z (5.2 min in the past)Not paused
exhaustedSince2026-10-23T01:16:15ZAn exhaustion episode is open — real refusals are happening
version67,453Optimistic-concurrency counter; bumped by a successful consume and by a refusal, by nothing else

So 6,077 spans 2026-09-29T16:00:52Z → now = 23.4 days, an average of 260/day. The current rate is nothing like that average. Two independent differenced reads:

ReadingAtthrottleCountversionImplied rate
the earlier audit~2026-10-23 00:03Z (its own guest-card timestamp)6,074—+3 in ~80 min ≈ 54 429s/day
mine, read 12026-10-23 01:21:50Z6,07767,453
mine, read 22026-10-23 01:28:24Z6,077 (+0)67,482 (+29)29 successful consumes in 6.6 min = 4.4/min against a 24/min capacity. All 29 are consumes, not refusals: a refusal is the only other writer that bumps version, and lastRefusedAt read 01:16:26Z at both samples.

The bulk of the 6,077 is therefore not a steady drip — it is front-loaded. CloudWatch confirms it, from the one log group that writes these lines:

aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
  --start-time <2026-09-28T00:00Z> --end-time <now> \
  --query-string 'fields @timestamp
    | filter @message like /situsgroup/ and @message like /noteThrottled/
    | stats count() as n by bin(24h) as day | sort day asc'
UTC daysync lambdaVercel runtimeNote
2026-09-2986925The storm. 772 in the 16:00Z hour, 97 in the 17:00Z hour, nothing else that day.
2026-09-30 → 2026-10-130 (14 days, no bins returned)11 total (Oct 8, 12, 13)Sync connection paused by staff. Positive control below. The handful of Vercel-side 429s are admin/Clara reads, which the pause does not stop.
2026-10-14234Pause lifted 20:05Z; last ~4h of the day only
2026-10-15 … 2026-10-2280, 55, 55, 20, 43, 49, 30, 2738, 39, 34, 31, 37, 27, 24, 34Steady state since the pause lifted: 50–120/day across both
2026-10-23 (partial, to 01:21Z)33—
Sum since 2026-09-281,254307vs throttleCount 6,077 — see §6

1b · Our own budget refusing our own callers: ~215/24h — no counter exists for this

This is the number that matters for starvation, and it is invisible on the status card. When acquire gives up it calls noteRefusal, which writes lastRefusedAt and exhaustedSince and no tally. The only durable record is a 15-minute-sticky flag — the exact mirror image of the pausedUntil trap the file already warns about, one over-reading and one under-reading. The refusal is visible only in a log line:

aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
  --start-time <now-24h> --end-time <now> \
  --query-string 'fields @timestamp, @message
    | filter @message like /account budget exhausted url=/ and @message like /situsgroup/
    | parse @message /caller=(?<caller>[^ "]+)/
    | stats count() as n by bin(24h) as day, caller'
UTC dayunattributed-appfolio-clientaccounting-syncrent-roll-historyTotal
2026-10-1616624—190
2026-10-1722357—280
2026-10-18134274165
2026-10-195975—602
2026-10-20204——204
2026-10-21185244213
2026-10-22172345211
2026-10-23 (to 01:21Z)13——13

Which pulls are losing, last 24h (215 refusals, parsed by report endpoint):

Report refusednReport refusedn
twelve_month_income_statement34unit_vacancy15
work_order27rental_applications13
aged_receivables_detail25guest_cards12
receivables_activity24tenant_directory12
delinquency23rent_roll_itemized5
rent_roll22tenant_tickler3

Refusals are spread across every report, not concentrated on one. Hourly over the same 24h they run 1–15/hour with a single 41/hour spike at 19:00Z — continuous all-day pressure, not one nightly batch. Work orders really are being refused (27 in 24h), which is the maintenance-intake starvation the dock was opened for; but the heaviest losses are the accounting reports (income statement, receivables, delinquency, rent roll, itemized rent roll: 133 of 215, 62%).

What I could not re-derive: the audit’s “126 other pulls skipped in the last 24 h.” My equivalent measurement on the same account and window is 215 budget refusals at the AppfolioClient.requestJson choke point. The 126 is presumably a count of sync skip rows (a coarser unit — one skip row can swallow several refused pages) from a different instrument. The two are the same family and the same direction; I did not find the query that produces exactly 126, so treat 215 as the receipt and 126 as unverified.

2 · Why does the budget never refill? It does. The premise is wrong.

Read, not inferred. All three live accounts at 2026-10-23 01:21:50Z:

Account (AppFolio database)tokenspausedUntil vs nowthrottleCountcounting since
appfolio#jpco#5cd04.93 / 620.5 min in the past1,2322026-09-16T01:41Z
appfolio#situsgroup#86fe5.0 / 65.2 min in the past6,0772026-09-29T16:00Z
appfolio#westernslopepm#17a55.0 / 612.2 min in the past5,5982026-09-16T00:21Z

Every one of the three is nearly full and none is paused. version advancing 29 times in 6.6 minutes on situsgroup is independent proof that consumes are succeeding, not CAS-wedged.

The mechanics, concretely

QuestionAnswer, from the code
Capacity and refillDEFAULT_CAPACITY = 6, DEFAULT_REFILL_WINDOW_MS = 15_000. Refill is continuous, not per-window: computeAvailability adds (elapsed / refillMs) * capacity, i.e. 1 token every 2.5s, clamped to 6. Every account gets the same 6/15s — getAppfolioAccountBudget accepts capacity/refillMs overrides and neither of the two production call sites passes either (client.ts:987, guest-card-conversation.ts:100), so Situs’s book and a one-building account draw from identically sized buckets.
What sets pausedUntilOnly noteThrottled, on a 429. Retry-After when the header is present and finite, else a random pick in the appfolioPolicy 2s–30s band, then Math.min(…, MAX_PAUSE_MS = 5 min). The write is a lengthen-only ratchet: ConditionExpression: attribute_not_exists(pausedUntil) OR pausedUntil < :pausedUntil.
What clears itNothing clears it. It expires. No code path lowers pausedUntil; tryConsumeOne’s successful write preserves it with if_not_exists(pausedUntil, :zero). The pause ends only by wall clock passing it, which on this ladder is 2–30s, capped at 5 min.
State the row is actually inSaturation by burst, not a wedge. Tokens 5/6, pause expired, exhaustedSince open from 01:16:15Z — i.e. refusals are happening in bursts and the bucket refills fully between them.

The two wedges that could pin this budget — and the receipt that neither is in effect

Worth stating because a future reader will reach for them, and because the fix row must not break their defences:

  1. A far-future pausedUntil. The ratchet only lengthens and nothing lowers it, so a malformed Retry-After written before MAX_PAUSE_MS existed would wedge every caller on the account permanently, unfixable without a hand write. Not the case here: pausedUntil is 5 minutes in the past, and the cap is in the code today.
  2. A missing version attribute. tryConsumeOne reads version ?? 0 and then conditions on version = :expectedVersion, which fails forever on a row where the attribute is absent — five contention retries, then waitMs: 25, then a refusal, every call. The file’s own comment calls this out. It self-heals, because both recordThrottleEvent and noteRefusal seed version with if_not_exists. Not the case here: version = 67,453 and advancing.
  3. A future lastRefillAt. elapsedMs = Math.max(now - lastRefillAt, 0), so a lastRefillAt written from a fast clock makes elapsed 0 forever; with stored tokens at 0 the bucket would read permanently empty and nothing heals it, since every other writer uses if_not_exists(lastRefillAt, …). This is the only shape that would actually produce “pinned empty.” Not the case here: lastRefillAt is 0.3 min in the past.
3 · What changed at 2026-09-29 16:00Z? No code. Situs was connected to AppFolio two minutes earlier.

I widened git log origin/main over src/lib/integrations/appfolio and lambda/appfolio-sync across 2026-09-26…10-02. Nothing landed in either path between 2026-09-29T08:07Z and 2026-09-30T03:50Z — a window that brackets the onset hour by 8 hours before and 12 after. The two obvious suspects are both a day late, as the brief said: #14255 (budget always enforced) merged 2026-09-30T17:27Z, #14072 (arm at connect) 2026-09-30T14:09Z. “No code landed at that hour” is the finding, and the real cause is a config event.

The onset was the AppFolio connection for situsgroup being created, followed immediately by its first full-portfolio walk against an account that was in observe mode, not enforce. Four independent receipts:

#ReceiptWhat it says
1Hourly CloudWatch, 2026-09-29 15:00–19:00Z, filter situsgroup + noteThrottled16:00Z hour: 772. 17:00Z hour: 97. Zero in the 15:00Z hour, zero after 18:00Z. 869 total — a two-hour storm with a hard edge at 16:00Z.
2Same window, all situsgroup lines by contextTraffic was reports/rental_applications and reports/guest_cards full-portfolio walks, and 385 lines of AppfolioClient.requestJson “account budget observed (not enforced)” — the budget was watching, not holding.
3CONFIG / APPFOLIO_BUDGET_ENFORCE#appfolio#situsgroup#86fe, prod DDBenforce: true, updatedBy: federicochapa, updatedAt: 2026-09-30T04:23:11Z, and the row’s own reason: “Situs connect 2026-09-29: 429 storm within minutes; Fede ‘fix’ in session 006 (same press as jpco 09-12, westernslopepm 09-13).” The answer was written down in prod three weeks ago.
4The handoff’s own AppFolio row“Connected Sep 29, 9:58 AM (database situsgroup)” = 15:58Z. The handoff states local times without a zone; MDT (UTC−6) is the right reading because the same page’s “armed by hand Sep 29, 10:23 PM” matches the ENFORCE row’s updatedAt of 2026-09-30T04:23:11Z exactly under MDT and under no other plausible zone. throttleCountSince is 16:00:52.125Z. Two minutes and fifty-two seconds after connect.

So 2026-09-29T16:00:52Z is both things at once, which is exactly how the misreading happened: it is the first 429 this account ever recorded (hence the counter’s start) and a genuine onset of a real two-hour storm. What it is not is the start of a condition that persisted.

Then the sync was switched off for fifteen days

The gap in the daily 429 table is not missing data — the log group’s retention is 30 days, so 2026-09-23 onward is retained and 2026-09-29 itself returns 869 rows. Positive control, so a zero proves the sweep ran: querying 2026-10-05 for any line containing situsgroup returns 2,076 matched records, and every one of them is context PMSSyncLambda:

connection=org_a3376147-…#situsgroup not dispatched — paused by staff

one of 2,076 identical lines on 2026-10-05, once per minute; zero AppfolioClient lines that day
WindowEvidence
First paused by staff line2026-09-29T17:01:57.647Z — 61 minutes after the storm started, while the 17:00Z hour’s 97 in-flight 429s were still landing
Daily counts1,805–2,076 per day, every day, 2026-09-30 through 2026-10-14
Last paused by staff line2026-10-14T20:04:57.541Z

So the true timeline is: connect 15:58Z → storm 16:00:52Z–~18:00Z (869 429s, budget in observe mode) → staff pause 17:01Z, holding 15 days with zero AppFolio traffic → enforcement armed by hand 2026-09-30T04:23Z, then made unconditional in code the next day (#14255) → pause lifted 2026-10-14T20:05Z → continuous, genuine budget pressure from then to now: 20–55 real 429s/day and 165–602 self-refusals/day.

4 · The root, not a bandage

Six commits have touched this budget. Every one of them changed whether the budget is enforced, or how loud it is. Not one changed how the single bucket is shared between callers.

PRDateWhat it triedWhy it did not hold
#791109-11Built the durable DynamoDB token bucket, no callersCorrect and still correct. It solved cross-process pacing. It never claimed to solve inter-caller fairness, and does not.
#790809-11Portfolio walker + process-local pacerThe walker is what produces the burst the bucket cannot absorb.
#792409-12Every request draws from the budget, armed off by defaultArmed off is why Situs stormed on connect: the Sep 29 traffic logged 385 “observed (not enforced)” lines.
#869509-15throttleCount/throttleCountSince + the status card, “counted not sampled”Right instinct, half-built: it counted AppFolio refusing us and left us refusing ourselves uncounted. That gap is the one this diagnosis had to fill from logs, and the one the audit misread.
#1425509-30Always enforced, no switchStopped the storm and created the starvation. Correct trade, but it converted an AppFolio-side problem into an internal-fairness problem and nothing then addressed fairness.
#1974110-14One signal per exhaustion episode, not one Sentry error per refusalFixed alert spam (25,311 events in one issue) by introducing exhaustedSince — a 15-minute-sticky flag with no counter beside it. It made refusals quiet without making them measurable. Landed the same day the Situs pause lifted.

The caller-side PRs are the same pattern one layer out: #13008, #13084 (renewals skip on a spent budget), #14937 (rental_applications skips instead of paging Sentry), #15644/#15648/#15656 (renewals un-park). Every one made an individual caller fail quietly when the budget refused it. That is the right local behaviour and it is also how 215 refusals a day became a silent data gap instead of an alarm. A seventh PR that makes one more caller skip gracefully is the bandage this rule exists to refuse.

5 · Recommended shape for budget-fair-share

The measurement that should drive the design: the bucket is 81% idle and still refuses ~215 calls a day.

QuantityValueSource
Capacity6 tokens / 15s = 24/min = 34,560/dayDEFAULT_CAPACITY, DEFAULT_REFILL_WINDOW_MS; no call site overrides either
Measured demand, situsgroup, last 24h6,574 requests = 4.6/min = 19.0% utilisationCloudWatch, AppfolioClient.requestJson GET lines by report
Top three consumersrental_applications 1,484 · work_order 1,454 · guest_cards 1,431 = 66.5%same query. Each ≈1,440/day = exactly one page per minute
Everything else2,205 across 11 reports, 4–5% eachsame query

The surviving measurement: who gets refused, by report. Refusal RATE — refused over (served + refused), one 24h window. It is per report, derived from no clock story, and it is the strongest number on this page:

Reportservedrefusedrefusal ratecadence
rent_roll_itemized6545.5%hourly bookkeeping class (dispatches at ~HH:21 — not :00; see the correction note)
twelve_month_income_statement573437.4%
aged_receivables_detail305257.6%
delinquency307237.0%
receivables_activity332246.7%
rent_roll309226.6%
unit_vacancy274155.2%
tenant_directory278124.1%
work_order1,454271.8%act_now, every minute (tenant_tickler excepted)
tenant_tickler30931.0%
rental_applications1,484130.9%
guest_cards1,431120.8%

The jobs that pull once a minute are refused 0.8–1.8% of the time. The jobs the hourly class pulls in a batch are refused 4–45% of the time — a 5× to 50× difference across a 1,700× range of volume. A report pulled 1,431 times a day is almost never refused; one pulled 11 times a day is refused half the time. That is the fairness defect, and it is the whole case for the fix below. It also settles one thing in the negative: the three 1-minute act_now jobs are the budget’s biggest consumers (66.5% of volume) and are not what is being starved, nor can they be emptying the bucket — at one page per minute each they put 3–4 requests/min against a 24/min capacity.

Two conclusions do survive and do not depend on the trigger: raising capacity is the wrong lever (it moves the refusal to AppFolio’s side, where 6,077 429s say we are already at the ceiling), and slowing the walks is also wrong — utilisation is 19%, so there is no volume problem to solve.

Two cautions on this table. The “served” column counts GET log lines emitted after a token was granted (client.ts:1044), so a request that was retried inside its own ladder is counted more than once — which biases the computed rates down, not up. And tenant_tickler pulls at batch volume but is refused at act_now rates, so its placement is uncertain; it is shown where its refusal rate puts it, not where its volume does.

Four parts as first drafted. Part 3 is withdrawn (see the correction note); three stand, vertically sliceable, each useful alone, in the order I would ship them:

PartChangeWhy it is first/next
1. Name every callerThread budgetCaller through every AppfolioClient construction site. Keep UNATTRIBUTED_BUDGET_CALLER as the type-level default but add a drift test that fails on any production construction site that does not pass one, with the current sites as a shrink-only allowlist. The sweep must cover the Vercel app and the renewal worker, not just lambda/ — the Vercel runtime carries 307 of the 1,561 logged 429s and the ECS worker touches situsgroup 1,935 times, so a lambda-only sweep leaves a real share of the traffic nameless.82% of refusals are unattributed. Every later part needs a caller identity to allocate against, and this one is observable the moment it lands — the refusal breakdown stops being a single anonymous bar.
2. Count refusals on the rowAdd refusalCount / refusalCountSince beside throttleCount, written by noteRefusal as its own unconditional ADD — the same reasoning recordThrottleEvent’s docstring already gives for why the tally cannot ride the conditional write. Surface it on the status card beside the 429 counter, with its own …Since label.This is the single change that would have prevented the misreading this row exists to correct, and it is the only honest before/after for parts 3–4. Today proving the fix works requires a CloudWatch Insights query; after this it is one get-item.
3. Spread the hourly batch WITHDRAWN 2026-10-23Do not build this. It was “give the hourly batch an offset inside the hour, or pace it to the bucket’s refill rate.” Withdrawn, not deferred, for three independent reasons: (a) there is no :00 spike to spread — bookkeeping already dispatches at HH:21:26; (b) per-report offsets would reverse #8136, Fede’s own cutover to one EventBridge rule per cadence class, which is a decision this row has no standing to re-open; (c) the pacing half is redundant rather than wrong — request-budget.ts already paces every walk at 6 / 15_000, the durable bucket’s own rate, with no non-test call site overriding it.Left in place, struck through, because a withdrawn recommendation that is deleted outright reads as a recommendation nobody made — and a Fix lane that read the first revision needs to find the retraction where it looked for the advice.
4. Per-class reservation inside the one bucketKeep one bucket per account — the account-wide AppFolio limit is real and must stay unreachable by construction. Give each cadence class plus interactive a reserved floor: a caller draws from its own reserve first and from a shared surplus pool above it. Concretely, tokens becomes a small per-class record, computeAvailability takes the caller’s class, and the CAS stays a single conditional write on version. The fail-open contract, the lengthen-only pause and computeAvailability-as-single-source all stay exactly as they are.The actual fairness fix. It is the part that makes a bookkeeping walk structurally unable to starve a work-order pull, and makes Clara’s 3s interactive call win against a background walk. Ship it last because parts 1–2 are what let anyone prove it worked.

Two constraints the fix lane must not break. (a) computeAvailability is deliberately the only place the refill arithmetic lives — a per-class version must stay a single function with a third caller, not a second copy; its own docstring records that an earlier duplicate was wrong by a factor of capacity on the one card meant to be trusted mid-incident. (b) Every write in this module fails open and silent; a broken budget must never stop the sync. Both parts 2 and 4 add writes, and both must keep that.

Interaction with incremental-guest-card-window: that row attacks the demand side of one of the three top consumers — guest_cards at 1,431 requests/day, 21.8% of all Situs AppFolio traffic, re-walking the full window once a minute. It reduces the burst; it does not change who wins when a burst happens. The two rows are complementary and neither substitutes for the other.

6 · What I could not establish
Reproduce everything above

The four commands, in order

# 1 — the row, and all three accounts (read-only)
aws dynamodb query --table-name propflow-prod \
  --key-condition-expression "PK = :pk AND begins_with(SK, :sk)" \
  --expression-attribute-values '{":pk":{"S":"CONFIG"},":sk":{"S":"APPFOLIO_BUDGET#"}}'

# 2 — 429 rate per day (widen --start-time to 2026-09-28 to see the storm and the gap)
aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
  --start-time $S --end-time $E --query-string 'fields @timestamp
    | filter @message like /situsgroup/ and @message like /noteThrottled/
    | stats count() as n by bin(24h) as day | sort day asc'

# 3 — our own refusals, by caller and by report
aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
  --start-time $S --end-time $E --query-string 'fields @timestamp, @message
    | filter @message like /account budget exhausted url=/ and @message like /situsgroup/
    | parse @message /reports\/(?<report>[a-z_]+)\.json/
    | stats count() as n by report | sort n desc'

# 4 — the config event that is the real onset
aws dynamodb get-item --table-name propflow-prod --key \
  '{"PK":{"S":"CONFIG"},"SK":{"S":"APPFOLIO_BUDGET_ENFORCE#appfolio#situsgroup#86fe"}}'
#   -> reason: "Situs connect 2026-09-29: 429 storm within minutes; ..."

Scope note, repeated because it is load-bearing: jpco, situsgroup and westernslopepm are separate AppFolio databases behind one login. Every number on this page is situsgroup unless the row says otherwise, and the jpco figures are present only as contrast. This page covers the Reports API path only — the L4 staff-portal browser path has no budget, no pacer and no counter, so nothing here is evidence about it either way.

PropFlow Docs