Scheduled Jobs and Proactive Outreach: Time Zones, Quiet Hours, Daily Caps, a Notification Provider Abstraction
Implement a scheduled job that proactively messages users: handle time-zone conversion, quiet hours, and a daily send cap correctly, and abstract the notification channel behind a swappable provider.
Today's Goals
- Correctly compute the local "time to send" per user's time zone
- Implement quiet-hours and daily-send-cap limiting logic
- Abstract sending a notification behind a provider interface that supports swapping channels
Yesterday (D19) connected the multi-agent service to mini-koda with user-level tokens, with the endpoints working and idempotency handled, and the whole system is still "the user speaks and the system does one thing." Today it learns to speak first — and to keep quiet before it does.
Plain-Language Walkthrough
Why building management does not knock at midnight
A building's management office deals with residents in two situations. In the first you go downstairs and ask: what happens now my parcel was returned? You are waiting for an answer, and whatever they say you take in. In the second, management decides to tell you something: the water goes off tomorrow, the lift is being serviced, the door fobs are being replaced. Nobody is waiting for that one; it barges in.
The same sentence is a service in the first situation and possibly an intrusion in the second. The difference is not content, it is who spoke first.
Back to the system. D13 finished "can send on schedule": a central scheduler wakes on a cron expression, publishes the task into the message bus, and the idempotency key is anchored to the scheduled minute, so multi-replica duplicate triggers still leave one row. That chain does not change one line today; today adds the layer that grows on top of it — whether it should send at all.
A user-initiated message and a system-initiated one are two different things in three respects:
| User-initiated | System-initiated | |
|---|---|---|
| Who is waiting | the user is watching the screen | nobody |
| On failure | must surface an error to the user | usually should quietly postpone or drop |
| On what basis | the user spoke | you have to state a reason yourself |
The third row is this chapter's thesis: the default answer for a proactive message is do not send. For every proactive message about to go out you must answer three questions — why now, why this user, and why this content is worth interrupting them. Fail any one and it should not be sent.
That is not a statement of values, it is arithmetic. Users' tolerance for proactive messages is very low: after a few inconsequential pushes in a row they will not argue about the content, they will simply turn notifications off — and once off, even the genuinely important message cannot reach them. What you spent was not this one instance of their attention, it was a quota that cannot be recovered once used.
So the engineering form of proactive care is not how to get a message out, it is how to have the vast majority of candidates stopped before they go out. This course makes that three gates, with the same rules as the management office: compute by the resident's own routine rather than the office's working hours (time zones), do not knock at midnight (quiet hours), and post at most one notice a day (a daily cap).
Of the three, the first is the easiest to get wrong and the most overlooked question is which step the gates are installed at.
Time has one storage form, and a judgment must convert
The management office reads the clock on its own wall when posting a notice, and the criterion for disturbing anybody is the clock in the resident's home.
The system's rule is two sentences, applied throughout: store all times in UTC and convert to the user's time zone before judging. What is stored is an absolute instant and what is judged is wall-clock time, and those are two different things that will cause an incident if merged into one field.
// JS has no time-zone-aware date type: a Date is just UTC milliseconds. To get the user's
// wall-clock time you have to format it into the target zone via Intl and read it back -
// the other three languages have native types and skip this detour.
function partsOf(zoneId, at) {
const f = new Intl.DateTimeFormat('en-CA', {
timeZone: zoneId, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})
const p = Object.fromEntries(f.formatToParts(at).map((x) => [x.type, x.value]))
// en-CA with hour12:false yields 24 at midnight, so take a modulus back to 0
return { date: `${p.year}-${p.month}-${p.day}`, hour: Number(p.hour) % 24, minute: Number(p.minute) }
}
function offsetMsAt(zoneId, at) {
const p = partsOf(zoneId, at)
const [y, m, d] = p.date.split('-').map(Number)
return Date.UTC(y, m - 1, d, p.hour, p.minute) - Math.floor(at.getTime() / 60000) * 60000
}
// The reverse: a local wall-clock time back to one unique UTC instant. Use the offsets a
// day either side as candidates, which covers both offsets on a transition day; then
// convert each candidate back to wall-clock time and check.
function resolveLocal(zoneId, date, hh, mi) {
const target = Date.UTC(...date.split('-').map(Number).map((v, i) => (i === 1 ? v - 1 : v)), hh, mi)
const cands = [...new Set([target - 86400000, target + 86400000]
.map((probe) => target - offsetMsAt(zoneId, new Date(probe))))].sort((a, b) => a - b)
const valid = cands.filter((c) => {
const p = partsOf(zoneId, new Date(c))
return p.date === date && p.hour === hh && p.minute === mi
})
if (valid.length === 0) return { instant: new Date(cands.at(-1)), kind: 'gap' }
if (valid.length > 1) return { instant: new Date(valid[0]), kind: 'ambiguous' }
return { instant: new Date(valid[0]), kind: 'exact' }
}from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def to_user_local(zone_id: str, at: datetime) -> datetime:
"""What comes in is always an aware UTC instant; convert to the user's zone before
judging. zoneinfo reads the system tz database, so you do not maintain DST rules."""
return at.astimezone(ZoneInfo(zone_id))
def resolve_local(zone_id: str, wall: datetime) -> tuple[datetime, str]:
"""A local wall-clock time back to one unique instant. PEP 495 expresses DST's two
anomalies with fold, and the order cannot be reversed: check existence first, then
check whether it occurred twice - because inside the spring-forward gap, fold=0 and
fold=1 also have differing offsets."""
aware = wall.replace(tzinfo=ZoneInfo(zone_id))
if aware.astimezone(timezone.utc).astimezone(aware.tzinfo) != aware:
# A round trip that does not return to itself means this local time does not exist
return aware.astimezone(timezone.utc), "gap"
if aware.utcoffset() != aware.replace(fold=1).utcoffset():
return aware.astimezone(timezone.utc), "ambiguous" # fold=0 is the first occurrence
return aware.astimezone(timezone.utc), "exact"// Dependencies: java.time (JDK 8+). An Instant is an absolute moment and only a
// ZonedDateTime has wall-clock time; keeping the types apart is java.time's value - you
// cannot use a zoneless local time as an absolute moment.
static ZonedDateTime toUserLocal(String zoneId, Instant at) {
return at.atZone(ZoneId.of(zoneId));
}
static Instant resolveLocal(String zoneId, LocalDate date, LocalTime time) {
var zone = ZoneId.of(zoneId);
var wall = LocalDateTime.of(date, time);
// getValidOffsets tells you how many valid offsets exist: empty means this local time
// does not exist (spring forward), two means it occurred twice (autumn back).
// ZonedDateTime.of's default resolution is exactly what we want: shift past the
// transition when absent, and take the earlier offset when doubled.
var offsets = zone.getRules().getValidOffsets(wall);
if (offsets.isEmpty()) log.warn("{} does not exist in {}, shifting past the transition", wall, zoneId);
if (offsets.size() > 1) log.warn("{} occurs twice in {}, taking the first", wall, zoneId);
return ZonedDateTime.of(wall, zone).toInstant();
}// A Date, like an Instant, is only an absolute moment; wall-clock time comes from a
// Calendar with a timeZone set.
func calendar(_ zoneId: String) -> Calendar {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: zoneId) ?? TimeZone(identifier: "UTC")!
return cal
}
func toUserLocal(_ zoneId: String, _ at: Date) -> DateComponents {
calendar(zoneId).dateComponents([.year, .month, .day, .hour, .minute], from: at)
}
enum Resolution { case exact(Date), gap(Date), ambiguous(Date) }
/// Calendar.date(from:) resolves as Java does: shift when absent, take the first when
/// doubled. It does not tell you which happened, so convert the result back to wall-clock
/// time and compare - that step is what makes the DST arithmetic add up.
func resolveLocal(_ zoneId: String, _ wall: DateComponents) -> Resolution? {
let cal = calendar(zoneId)
guard let date = cal.date(from: wall) else { return nil }
let back = cal.dateComponents([.hour, .minute], from: date)
guard back.hour == wall.hour, back.minute == wall.minute else { return .gap(date) }
// Inside the autumn fallback hour, an instant one DST offset later yields the same
// wall-clock time. daylightSavingTimeOffset returns a TimeInterval in seconds.
let shift = cal.timeZone.daylightSavingTimeOffset(for: date)
let later = date.addingTimeInterval(shift > 0 ? shift : 3600)
let laterBack = cal.dateComponents([.hour, .minute], from: later)
if laterBack.hour == wall.hour, laterBack.minute == wall.minute { return .ambiguous(date) }
return .exact(date)
}Put the four side by side and one thing is clear: Python, Java, and Swift all have a time-zone-aware date type in their standard library and JS does not, which is why only the JS version computes offsets by hand. Somebody hand-writing time-zone arithmetic in a JS project is not being clever, they have no choice.
Four concrete traps, each with a criterion.
One, do not store local time strings. A string like 2026-09-06 08:00 points at no definite moment: the same string differs by 12 hours in Shanghai and New York, and another machine or a changed TZ variable interprets it differently again. The criterion is whether this column plus the user's stored time zone uniquely recovers that absolute moment — a local string cannot answer, a UTC timestamp can.
Two, store an IANA identifier for the time zone, not an offset. Storing +08:00 rather than Asia/Shanghai shows no fault in regions without DST, and the moment your user is in New York, London, or Sydney the offset changes twice a year — it was already stale the instant you stored it. An IANA identifier is a rule; an offset is only what that rule computed on one day.
Three, the DST transition day. This is the part most worth being pedantic about. Two anomalies appear:
- Spring forward: local 02:30 on 8 March 2026 in New York does not exist, as clocks jump from 02:00 to 03:00;
- Autumn back: local 01:30 on 1 November occurs twice, once in DST and once in standard time.
Whenever a quiet-hours window's end time falls into either, "postpone until 8am tomorrow" resolves to no unique answer. A vague "watch out for DST" is useless; you need a resolution you can write an assertion for. This course takes the same one as java.time: when it does not exist, shift to the corresponding moment past the transition (02:30 becomes 03:30), and when it occurs twice, take the first. Today's lab runs both as self-checks, and all four languages produce identical UTC instants on those two inputs.
Four, when a user travels across time zones, which zone is theirs. The criterion is the explicit field in the user's profile, never a silent drift following the device: a device-reported zone only prompts the question of whether to move their routine too, and it is written only if the user agrees. Also record when the profile's zone was last updated, and when it jumps more than 3 hours within 24 hours (this person really is flying), treat that day's quiet hours as the union of the old and new zones — quiet on either side means do not send. Being conservative costs a few hours' delay; being aggressive costs a chime at three in the morning.
Quiet hours: nearly everybody gets the cross-midnight check wrong the first time
Management's rule is no knocking after 10pm until 8am the next day. That interval has one deadly difference from "one to two in the afternoon for lunch": it crosses midnight.
This course fixes the convention: quiet hours default to 22:00 to 08:00 in the user's local time, closed at the start and open at the end — 22:00 exactly is quiet and 08:00 exactly is sendable.
Once times are converted into minutes from midnight, almost everybody writes start <= now && now < end first. For a same-day interval like lunch that is right; for 22:00 to 08:00, start is 1320 and end is 480, and that condition never holds, so you send happily through the night. The nastiest part is that it is wrong only for cross-midnight configurations — write a unit test with 13:00 to 14:00 and it is all green.
The correct form uses "and" when start is less than end and switches to "or" when start is greater.
const minutesOf = (hhmm) => hhmm.split(':').reduce((h, m) => Number(h) * 60 + Number(m))
// Closed-open [start, end). start greater than end means it crosses midnight, and the
// condition must switch from "and" to "or"
export function isQuiet(quietStart, quietEnd, hour, minute) {
const now = hour * 60 + minute
const start = minutesOf(quietStart)
const end = minutesOf(quietEnd)
if (start === end) return false // empty interval: this user has no quiet hours
return start < end ? now >= start && now < end : now >= start || now < end
}
// Which day the window ends on: local 23:30 pushes to 08:00 tomorrow, local 07:00 pushes
// to 08:00 today. For one quietEnd, which day depends on which side of midnight you are.
export function endDate(quietStart, quietEnd, localDate, hour, minute) {
const crossesMidnight = minutesOf(quietStart) > minutesOf(quietEnd)
const sameDay = !crossesMidnight || hour * 60 + minute < minutesOf(quietEnd)
if (sameDay) return localDate
const [y, m, d] = localDate.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10)
}from datetime import date as Date, time as Time, timedelta
def is_quiet(quiet_start: Time, quiet_end: Time, now: Time) -> bool:
"""Closed-open. time values compare directly with no conversion to minutes - and the
cross-midnight case still switches from and to or, no different from any language."""
if quiet_start == quiet_end:
return False
if quiet_start < quiet_end:
return quiet_start <= now < quiet_end
return now >= quiet_start or now < quiet_end
def end_date(quiet_start: Time, quiet_end: Time, local_date: Date, now: Time) -> Date:
"""Which day the window ends on: local 23:30 to 08:00 tomorrow, local 07:00 to today."""
same_day = quiet_start <= quiet_end or now < quiet_end
return local_date if same_day else local_date + timedelta(days=1)// A record keeps the configuration and the judgment together, cleaner than a static
// method taking two LocalTimes everywhere
record QuietHours(LocalTime start, LocalTime end) {
/** Closed-open [start, end). start after end crosses midnight, so and becomes or. */
boolean covers(LocalTime now) {
if (start.equals(end)) return false;
return start.isBefore(end)
? !now.isBefore(start) && now.isBefore(end)
: !now.isBefore(start) || now.isBefore(end);
}
}
/** Which day the window ends on: local 23:30 to 08:00 tomorrow, local 07:00 to today. */
static LocalDate endDateOf(QuietHours q, ZonedDateTime local) {
boolean crossesMidnight = q.start().isAfter(q.end());
boolean sameDay = !crossesMidnight || local.toLocalTime().isBefore(q.end());
return sameDay ? local.toLocalDate() : local.toLocalDate().plusDays(1);
}struct QuietHours {
let start: Int // minutes from local midnight
let end: Int
/// Closed-open [start, end). start greater than end crosses midnight, so and becomes or.
func covers(_ minutes: Int) -> Bool {
if start == end { return false }
return start < end ? (minutes >= start && minutes < end)
: (minutes >= start || minutes < end)
}
}
/// Which day the window ends on. DateComponents allows out-of-range fields and leaves the
/// carry to Calendar, so no month-end arithmetic here - far safer than hand-writing
/// "how many days does this month have".
func endDay(_ quiet: QuietHours, localMinutes: Int, today: DateComponents) -> DateComponents {
let sameDay = quiet.start <= quiet.end || localMinutes < quiet.end
guard !sameDay, let day = today.day else { return today }
var next = today
next.day = day + 1
return next
}With the check right, a second decision remains: hitting quiet hours means postponing, not discarding.
The criterion should not be the sender's mood at the time but something the message carries: give every candidate an expiry, and discard those whose expiry precedes the window's end while postponing the rest. "This order auto-cancels in thirty minutes" is meaningless after tonight, so discarding is right; "this month's statement is ready" is equally valid at 8am, so discarding loses a service for nothing.
The moment to postpone to has to be computed correctly too: local 23:30 postpones to tomorrow's 08:00 and local 07:00 to today's 08:00; convert that wall-clock time back to one unique UTC instant with the previous section's machinery and store it in a delay queue ordered by due time.
The daily cap: at most one notice a day, and "a day" is the resident's day
This course fixes the daily proactive-message cap at 3 per user. That gate is three lines of code with three questions that must be settled.
One, whose day is "a day." Making the counter key a UTC date is the most common approach and it is wrong: a UTC+8 user's local day starts at 16:00 the previous UTC day, so the message they receive at 8am counts against yesterday's quota while they see it as today's first. The counting day must be the user's local calendar day, and the key looks like this:
notify:count:u-1042:2026-09-06
^^^^^^^^^^ the user's local calendar day, not the UTC dayTwo, check-then-write or claim-first. Checking then judging is instinctive and wrong: under concurrency two candidates both read 2, both judge themselves within the cap, both send, and the user gets 4 that day. The correct move claims first and judges after — increment atomically and compare the post-increment return against the cap, releasing the slot if it exceeded. That release cannot be skipped: without it, a channel rejecting outright (invalid body, user unsubscribed) costs the user a quota slot for nothing.
Three, which step the gate is installed at. This is the chapter's most expensive point.
// Three gates in fixed order: time zone, quiet hours, daily cap.
// The cap gate goes last, because a postponed message should not consume today's quota -
// it sends tomorrow morning, so why count it today.
// Position matters more than order: this whole function must run BEFORE the model writes the body.
export async function passGates(store, profile, candidate, at) {
const parts = partsOf(profile.timeZone, at) // gate one: convert to the user's local time
if (isQuiet(profile.quietStart, profile.quietEnd, parts.hour, parts.minute)) {
const dueAt = quietEndsAt(profile, at) // gate two: quiet hours
if (candidate.expiresAt && new Date(candidate.expiresAt) <= dueAt) {
return { action: 'drop', reason: 'expires before the window ends', localDate: parts.date }
}
return { action: 'defer', dueAt, localDate: parts.date }
}
const used = await store.bumpDaily(profile.id, parts.date) // gate three: claim, then judge
if (used > profile.dailyLimit) {
await store.releaseDaily(profile.id, parts.date) // release what was not used
return { action: 'drop', reason: 'over the daily cap', localDate: parts.date }
}
return { action: 'send', reason: `number ${used} today`, localDate: parts.date }
}async def pass_gates(store, profile: Profile, candidate: Candidate,
at: datetime) -> Verdict:
"""Three gates in fixed order: time zone, quiet hours, daily cap.
The cap gate goes last, because a postponed message should not consume today's quota.
Position matters more than order: this must run BEFORE the model writes the body."""
local = at.astimezone(ZoneInfo(profile.time_zone)) # gate one
today = local.date()
if is_quiet(profile.quiet_start, profile.quiet_end, local.time()): # gate two
due_at = quiet_ends_at(profile, at)
if candidate.expires_at is not None and candidate.expires_at <= due_at:
return Verdict("drop", "expires before the window ends", today)
return Verdict("defer", "inside quiet hours", today, due_at=due_at)
used = await store.bump_daily(profile.id, today) # gate three: claim, then judge
if used > profile.daily_limit:
await store.release_daily(profile.id, today) # release what was not used
return Verdict("drop", "over the daily cap", today)
return Verdict("send", f"number {used} today", today)enum Action { SEND, DEFER, DROP }
record Verdict(Action action, String reason, LocalDate localDate, Instant dueAt) {}
/**
* Three gates in fixed order: time zone, quiet hours, daily cap.
* The cap gate goes last, because a postponed message should not consume today's quota.
* Position matters more than order: this must run BEFORE the model writes the body.
*/
static Verdict passGates(Store store, Profile p, Candidate c, Instant at) {
var local = at.atZone(ZoneId.of(p.zoneId())); // gate one
var today = local.toLocalDate();
if (p.quiet().covers(local.toLocalTime())) { // gate two
var dueAt = quietEndsAt(p, local);
if (c.expiresAt() != null && !c.expiresAt().isAfter(dueAt))
return new Verdict(Action.DROP, "expires before the window ends", today, null);
return new Verdict(Action.DEFER, "inside quiet hours", today, dueAt);
}
long used = store.bumpDaily(p.id(), today); // gate three: claim, then judge
if (used > p.dailyLimit()) {
store.releaseDaily(p.id(), today); // release what was not used
return new Verdict(Action.DROP, "over the daily cap", today, null);
}
return new Verdict(Action.SEND, "number " + used + " today", today, null);
}// An enum with associated values: dueAt exists only for defer, pinning "no value when
// there should be none" into the type rather than a Date? checked for nil everywhere
enum Verdict {
case send(reason: String, localDate: String)
case postpone(dueAt: Date, localDate: String)
case drop(reason: String, localDate: String)
}
/// Three gates in fixed order: time zone, quiet hours, daily cap.
/// The cap gate goes last, because a postponed message should not consume today's quota.
/// Position matters more than order: this must run BEFORE the model writes the body.
func passGates(_ store: Store, _ p: Profile, _ c: Candidate, at: Date) async -> Verdict {
let cal = calendar(p.zoneId) // gate one
let parts = cal.dateComponents([.year, .month, .day, .hour, .minute], from: at)
let today = String(format: "%04d-%02d-%02d", parts.year!, parts.month!, parts.day!)
if p.quiet.covers(parts.hour! * 60 + parts.minute!) { // gate two
let dueAt = quietEndsAt(p, at)
if let expiresAt = c.expiresAt, expiresAt <= dueAt {
return .drop(reason: "expires before the window ends", localDate: today)
}
return .postpone(dueAt: dueAt, localDate: today)
}
let used = await store.bumpDaily(p.id, today) // gate three
guard used <= p.dailyLimit else {
await store.releaseDaily(p.id, today) // release what was not used
return .drop(reason: "over the daily cap", localDate: today)
}
return .send(reason: "number \(used) today", localDate: today)
}Proactive care's complete chain is this:
cron matches -> pick candidate users -> three gates -> model writes the body -> channel
(D13 done) ^^^^^^^^^^^ install here
^^^^^^^^^^^^^^ too late hereGet the order wrong and the program still runs and the messages sent are identical, the only difference being that every message you stopped had already cost one model call. By D13's price table: one proactive message's body at 3,000 input and 500 output tokens is 0.00075 dollars; 1,000 users with 3 candidates each daily is 3,000, and if quiet hours and the cap together stop forty percent, 1,200 a day are generated for nothing:
- Wasted daily: 1200 times 0.00075 equals 0.9 dollars
- Wasted monthly: 0.9 times 30 equals 27 dollars
Compare D13's arithmetic — the same 1,000 users, one normal conversation a day, 22.5 dollars a month — and the money wasted by that one ordering mistake exceeds the business's own spend. Worse, monitoring cannot see it: the call succeeded, the message went out, and the ledger row looks exactly like a normal call. Only putting candidate count and actual send count side by side surfaces the gap — which is what today's fifth self-check does.
The notification provider: the same notice by SMS or on the noticeboard
The same water-outage notice can go out by SMS, on the stairwell noticeboard, or in the residents' group chat. Which channel has nothing to do with the content, it is a decision on another axis.
You have seen this shape twice: D4's model provider and payments' PaymentProvider. Today is the third, and anything replaceable belongs behind an interface is no longer news. What is worth saying is the next sentence: a notification interface must not be copied from the payment one, because three of its semantics differ completely.
Accepted is not delivered. A payment gateway returning success means the money moved; a notification channel returning success means only that it accepted the message, and whether it actually reached the user's phone arrives asynchronously via a receipt later. So the return value can only be called accepted, not delivered, and it must carry a channel-side message identifier — that is how the receipt is matched later.
Rate limiting belongs to the channel. An SMS route has a per-second ceiling and tells you how long to wait when it returns 429. That is a channel-level technical constraint, distinct from the daily cap just built (a user-level courtesy constraint), and merging them makes both untunable: one says this line is full, the other says this person has been bothered enough today.
There is no cancellation. Payments have refunds; notifications do not. Once handed to a channel a message cannot be recalled, and "cancel" is only meaningful before handing it over. So the interface must not contain cancel — an operation that cannot be performed is more dangerous than no operation at all, because callers will genuinely use it.
// Delivery is an asynchronous receipt, so this only expresses whether the channel accepted
// it, and the three outcomes are handled completely differently
export async function deliver(provider, notification, sleep, maxAttempts = 3) {
for (let attempt = 1; ; attempt += 1) {
// A retry must carry the same idempotencyKey: an extra database row you can delete,
// an extra chime on the user's phone you cannot
const result = await provider.send(notification)
if (result.status === 'accepted') return { ok: true, attempts: attempt }
if (result.status === 'rejected') return { ok: false, attempts: attempt } // bad argument, retrying will not help
if (attempt >= maxAttempts) return { ok: false, attempts: attempt }
await sleep(result.retryAfterMs) // wait as long as the channel says, do not improvise
}
}class NotificationProvider(Protocol):
id: str
rate_limit_per_sec: int # the channel's own technical limit, distinct from the daily cap
async def send(self, n: Notification) -> SendResult: ... # no cancel: once sent, no recall
async def deliver(provider: NotificationProvider, n: Notification,
max_attempts: int = 3) -> tuple[bool, int]:
"""match dispatches by result type, and a static checker warns you to add a branch
when a new result appears."""
for attempt in range(1, max_attempts + 1):
match await provider.send(n): # a retry carries the same idempotency_key
case Accepted():
return True, attempt
case Rejected():
return False, attempt # bad argument; ten thousand retries stay wrong
case Throttled(retry_after_ms=ms) if attempt < max_attempts:
await asyncio.sleep(ms / 1000) # asyncio.sleep takes seconds
return False, max_attempts// A sealed interface: three outcomes are a closed set, and nobody can quietly add a
// fourth in another package
sealed interface SendResult {
record Accepted(String providerMessageId) implements SendResult {}
record Throttled(Duration retryAfter) implements SendResult {}
record Rejected(String reason) implements SendResult {}
}
interface NotificationProvider {
String id();
int rateLimitPerSec(); // the channel's technical limit, not the daily cap
SendResult send(Notification n); // no cancel: a sent message cannot be recalled
}
static boolean deliver(NotificationProvider p, Notification n, int maxAttempts)
throws InterruptedException {
for (int attempt = 1; ; attempt++) {
var result = p.send(n); // a retry must carry the same idempotencyKey
if (result instanceof SendResult.Accepted) return true;
if (result instanceof SendResult.Rejected) return false; // bad argument
if (result instanceof SendResult.Throttled t) {
if (attempt >= maxAttempts) return false;
Thread.sleep(t.retryAfter().toMillis()); // Duration converts units for you
}
}
}enum SendResult {
case accepted(providerMessageId: String)
case throttled(retryAfter: TimeInterval) // TimeInterval is in seconds, not milliseconds
case rejected(reason: String)
}
protocol NotificationProvider {
var id: String { get }
var rateLimitPerSec: Int { get } // the channel's technical limit, not the daily cap
func send(_ n: Notification) async throws -> SendResult // no cancel: once sent, no recall
}
func deliver(_ provider: some NotificationProvider, _ n: Notification,
maxAttempts: Int = 3) async throws -> Bool {
var attempt = 0
while true {
attempt += 1
// The switch covers every case, so adding an outcome forces you back here
switch try await provider.send(n) { // a retry carries the same idempotencyKey
case .accepted: return true
case .rejected: return false // bad argument; ten thousand retries stay wrong
case .throttled(let retryAfter):
if attempt >= maxAttempts { return false }
try await Task.sleep(for: .seconds(retryAfter))
}
}
}Retry semantics therefore split three ways, and one rule will not cover all of them: on throttling, wait as long as the channel says; on a bad argument (empty body, invalid number, user unsubscribed), give up outright and release today's quota slot; on a server error or timeout, retry but always carrying the same idempotency key. That is the fourth appearance of idempotency keys in this course (D8's persistence, D13's scheduled trigger, D19's cross-service, today's retry), and notifications carry the highest stakes.
Source Reading
Hands-On Lab
This lab needs zero external services under MOCK=1: the counter and the delay queue use in-memory implementations (not stubs — atomic claim-first increments and due-time retrieval are genuinely implemented), the clock is injectable, and the self-check crosses the user's local midnight within a millisecond. The starter has 5 exercise points, and each blank defaults to something that runs and is visibly wrong — judging routine by UTC, always sendable across midnight, discarding on quiet hours, counting without stopping, and gates installed after generation. Run it as-is first and read which exercise number each of those 5 failures names. With Docker, docker compose up -d in the lab root and rerun with REDIS_URL and DATABASE_URL, and the infrastructure line changes from memory to real with all six results identical.
- Complete the per-user-zone local-time conversion and rerun, watching check 1 turn green: three zones give three different local times at one instant, and the two DST days are judged gap and ambiguous.
- Change the quiet-hours check to handle crossing midnight, and check 2's seven times go from all sendable to 22:00 through 07:59 quiet with 21:59 and 08:00 sendable.
- Compute the UTC instant of the quiet window's end, and check 3 goes from "sent in the middle of the night" to "postponed to 08:00 the next day," while the one with an expiry is still discarded.
- Add the daily-cap check to the third gate, watch the New York user stopped from the fourth message onwards, then cross their local midnight and see the quota reset.
- Move the three gates ahead of body generation, and check 5 prints model calls exactly equalling sends with 0 dollars wasted, while telling you how much a late gate would waste.
Interview Questions
Today's four questions are in the bank below, weighted toward time-zone traps, rate limiting, and interruption control. Expand a question and read the analysis before the key points — the follow-ups on question 2 (why this class of bug is always green in tests) and question 3 (which of the three gates comes first) are the two most likely to be pressed, so do not skip them. Each is tagged for the China-domestic or overseas market so you can prioritize by where you are applying.
Checklist and Tomorrow
- Correctly compute the local "time to send" per user's time zone
- Implement quiet-hours and daily-send-cap limiting logic
- Abstract sending a notification behind a provider interface that supports swapping channels
- Write the correct cross-midnight quiet-hours check without notes, and say why the naive form is always false
- Say why the three gates must sit before body generation, and how much a late gate wastes per month
- All 6 self-check criteria of the lab pass
- Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D21) closes out W3. As of today the system triages, splits tasks and runs them in parallel, reviews itself, and reaches out proactively while knowing restraint — and you cannot actually say how well it does any of it, holding only "I tried a few and it seemed fine." Tomorrow adds the last piece: measure it against a fixed golden set, score with a model scoring a model while understanding how trustworthy that is, and turn the call chain, the failure rate, and cost into a dashboard you can read. Build the system before discussing how to evaluate it, and that order cannot be reversed — a system with no evaluation is the kind you never dare to change.
Interview questions
How does a system-initiated message differ from a user-triggered one, from a system design point of view?系统主动发给用户的消息,和用户自己触发的消息,在系统设计上有什么不同?
Common in ChinaCommon overseasBasic#proactive-messaging#system-design#product-engineeringHow to reason about it · think before answering
- This looks like a definition question but it is really a filter. Answering both send a message, only the trigger differs stays at the shallowest layer — the interviewer wants to know what extra code the difference forces you to write.
- Give three structured differences: who is waiting (a user-triggered reply has someone staring at the screen, a proactive message has nobody waiting); how failure is handled (user-triggered failures must surface as errors, proactive failures should usually be silently deferred or dropped); and what justifies sending (the user asked, versus you having to justify it yourself).
- The third is the hinge, so make it explicit: the default answer for a proactive message is do not send. Every one must answer why now, why this user, and why this content is worth interrupting them. Fail any of the three and it should not go out.
- Then land the difference in the system: the proactive path needs an admission layer the reactive path does not — compute the user's local time from their timezone, defer if it falls inside quiet hours, drop if the daily cap is used up.
- Quantify the cost, which is what separates having read about this from having shipped it: tolerance for proactive messages is very low. After a few irrelevant pushes the user will not argue about the content, they will revoke the notification permission — and once revoked, the genuinely important message cannot reach them either. You are spending a budget that never refills.
- Expect: so is a cron job the same thing as a proactive message? No. The scheduler solves firing on time (central scheduling, an idempotency key anchored to the scheduled minute); proactive care solves whether to send at all. One is mechanism, the other is admission, and they belong in separate layers.
分析过程 · 先想清楚再作答
- 这题看着像概念题,其实是筛人题。答「都是发消息,只是触发方不同」就落进了最浅的一层——面试官想听的是这个差别会逼你多写哪些代码。
- 先给三条结构化的差别:谁在等(用户触发时他正盯着屏幕,主动消息没有人在等);失败怎么处理(用户触发的失败必须报错给他看,主动消息的失败多数时候应该安静地推迟或放弃);凭什么发(用户触发是他开了口,主动消息你得自己说出理由)。
- 第三条是题眼,要说透:主动消息的默认答案是不发。每一条都要能回答为什么是现在、为什么是这个用户、为什么这条内容值得打断他,三个问题答不上任何一个就不该发。
- 然后给出这个差别在系统里的落点:主动消息这一侧必须多出一层准入判断,本课叫三道闸——按用户时区算本地时间、安静时段命中就推迟、每日上限满了就拦下。用户触发那一侧完全不需要这层。
- 代价也要算清楚,这是区分「读过文章」和「做过系统」的地方:用户对主动消息的容忍度极低,连着几条无关紧要的推送之后他不会争论内容对不对,直接关掉通知权限——而权限一关,你连真正重要的那条也送不出去了。你消耗的是一个用完就拿不回来的额度。
- 可以预期的追问:那定时任务和主动消息是不是一回事?答不是。定时任务解决的是「能按时触发」(中心调度、幂等键锚在计划触发的那一分钟),主动消息解决的是「该不该发」,前者是机制、后者是准入,两层要分开做。
Key points
- Three differences: who is waiting, how failure is handled, and what justifies sending — the third is the crux
- The default answer for a proactive message is no; each one must justify why now, why this user, why worth interrupting
- In the system this becomes an admission layer — timezone, quiet hours, daily cap — that the reactive path does not need
- The cost is a non-renewable budget: annoy the user and they revoke notifications, taking the important messages down with them
- Scheduling (fire on time) and proactive care (should we send) are two separate layers
答题要点
- 三条差别:谁在等、失败怎么处理、凭什么发;第三条是关键
- 主动消息的默认答案是不发,每条要能回答为什么是现在、为什么是这个用户、为什么值得打断他
- 落到系统上就是多一层准入判断:时区换算、安静时段、每日上限,用户触发那一侧不需要
- 代价是一个不可再生的额度:推送惹烦了用户,他关掉权限之后重要消息也送不出去
- 定时机制(能按时触发)和主动关怀(该不该发)是两层,不要混在一起做
Building a scheduled push service for users worldwide, what timezone pitfalls would you hit, and how do you handle the DST switchover day?做一个面向全球用户的定时推送服务,时区上你会踩到哪些坑?夏令时切换那天怎么处理?
Common in ChinaCommon overseasDeep dive#timezone#scheduling#correctnessHow to reason about it · think before answering
- All the signal in this question lives in the DST half. Store UTC, render local is the passing grade; giving a verifiable ruling for the switchover day is what separates knowing the pitfall from having fixed it.
- Nail the two basics first: always store UTC (an absolute instant) and convert to the user's zone before any judgment (a wall-clock time). The self-check is one sentence — can this column plus the user's stored timezone uniquely reconstruct the absolute instant? A local time string cannot.
- The second pitfall is the timezone field itself: store the IANA identifier (Asia/Shanghai), never a UTC offset. Offsets shift twice a year under DST; the identifier is the rule and the offset is only what that rule evaluated to on one particular day, so it is stale the moment you persist it.
- The third is the two anomalies on switchover day: spring-forward makes some local time simply not exist (02:30 on 2026-03-08 in New York), and fall-back makes some local time occur twice (01:30 on 2026-11-01). If your schedule point lands in either window, send at 8am local has no unique answer. State the ruling explicitly rather than leaving it to whatever the library picks: shift a nonexistent time forward past the transition (02:30 becomes 03:30), and take the first occurrence when it happens twice — which is exactly what java.time's ZonedDateTime.of does, so you can assert on it in tests.
- The fourth is the one people miss: when a user travels across zones, which timezone counts. Answer: the explicit field on the user profile, never silent drift from device reports; a device report should only prompt the user to confirm a change. Go one level deeper if you can — when the zone jumps more than three hours within 24 hours, treat that day's quiet hours as the union of the old and new zones and stay silent if either is quiet. Being conservative costs a few hours of delay; being aggressive costs a 3am buzz.
- Expect: why are these bugs so hard to catch? Because your laptop, CI and production are often all in one zone, frequently UTC, so forgot to convert stays green everywhere. Give the fix: pin the test users to three distinct zones, none equal to the server's, and every server-timezone dependency turns red immediately.
分析过程 · 先想清楚再作答
- 这题的区分度全在夏令时那半句。只答「存 UTC、展示转本地」是及格线,能不能给出夏令时那天的**可验证裁定**决定了你是「知道有坑」还是「填过坑」。
- 先把基础两条说死:时间一律存 UTC(存的是绝对时刻),判断前先转成用户本地时区(判断的是墙上时间)。判据是一句可自查的话——只靠这一列加上用户档案里的时区,能不能唯一还原出那个绝对时刻。存本地时间字符串答不上来。
- 第二个坑是时区字段本身:必须存 IANA 标识(Asia/Shanghai)而不是 UTC 偏移量。偏移量一年会随夏令时变两次,标识是规则、偏移量只是规则在某一天算出来的结果,存结果的那一刻它就过期了。
- 第三个坑是夏令时那天的两种反常:春季前跳会让某个本地时间**根本不存在**(纽约 2026-03-08 的 02:30),秋季回拨会让某个本地时间**出现两次**(2026-11-01 的 01:30)。只要你的调度点落在这两个窗口里,「每天早上 8 点发」就解释不出唯一答案。裁定要显式给出而不是交给库随便选:不存在就顺延到过渡之后(02:30 变 03:30),出现两次就取第一次——这也正是 java.time 的 ZonedDateTime.of 的默认行为,可以直接写成断言测试。
- 第四个坑最容易被漏:用户跨时区旅行时,他的时区以哪一次为准。答案是以用户档案里那个显式字段为准、绝不跟着设备静默漂移;设备上报只用来询问是否切换。更细一层可以补:时区在 24 小时内跳变超过 3 小时时,当天的安静时段按新旧两个时区的并集处理,任何一边在安静就不发——保守的代价是晚几小时收到,激进的代价是在人家凌晨三点响一声。
- 可以预期的追问:这种 bug 为什么很难被测出来?因为本机、CI、生产常常都在同一个时区甚至都在 UTC,「忘了转时区」在所有测试里都是绿的。给出判据:把测试用户的时区故意设成三个互不相同、且都不等于服务器时区的值,任何依赖服务器时区的判断当场变红。
Key points
- Store UTC everywhere, convert to the user's zone before judging; the check is whether column plus zone reconstructs the instant
- Persist IANA identifiers, not UTC offsets — offsets change twice a year and are stale on write
- Two DST anomalies: a local time that does not exist (spring forward) and one that occurs twice (fall back)
- Make the ruling explicit and assertable: shift nonexistent times past the transition, take the first of a duplicated pair (matching java.time)
- For travellers, trust the explicit profile field, not device drift; on jumps over three hours, treat quiet hours as the union of both zones
- Pin test users to three zones different from the server's, or the missing conversion stays green in every test
答题要点
- 存储一律 UTC,判断前转用户本地时区;自查判据是这一列加时区能否唯一还原绝对时刻
- 时区存 IANA 标识而不是 UTC 偏移量——偏移量随夏令时一年变两次,存下来就过期
- 夏令时两种反常:本地时间不存在(春季前跳)、本地时间出现两次(秋季回拨)
- 裁定要显式且可断言:不存在就顺延到过渡之后,出现两次取第一次(与 java.time 默认一致)
- 跨时区旅行以用户档案里的显式字段为准,不跟设备漂;跳变超过 3 小时时按新旧时区的并集判安静
- 测试里把用户时区设成三个不同于服务器的值,否则「忘了转时区」在所有测试里都是绿的
How would you implement quiet hours and a per-user daily cap, and where in the pipeline should they be evaluated?quiet hours 和每日发送上限这两条规则你会怎么实现?它们应该放在链路的哪一步判断?
Common in ChinaCommon overseasIntermediate#rate-limiting#quiet-hours#cost-controlHow to reason about it · think before answering
- There are two hinges here and most candidates only answer the first. One is how to evaluate the rules (a details question), the other is where in the pipeline (an architecture question) — the second is where the points are.
- Start with quiet hours. Once you fold times into minutes-from-midnight, almost everyone first writes start less-or-equal now and now less-than end. That is correct for a same-day window like a lunch break, but for 22:00 to 08:00 it is always false: start is 1320, end is 480, the condition never holds, and you push at 3am. The fix is to use and when start is before end, and or when start is after end. What makes this bug nasty is that it only misfires on the cross-midnight config, so a unit test written around 13:00 to 14:00 passes.
- Then what to do on a hit: defer, do not drop. The decision should not be the sender's mood — attach an expiry to each candidate and drop only when it expires before the window ends, deferring everything else to the window's end. A thirty-minute cancellation warning is worthless tomorrow; a billing summary is just as valid at 8am. Mention the thundering herd too: every deferred message resolves to the same due instant, so add jitter derived from a hash of the user id, never a random number, or you cannot reproduce incidents.
- Now two details on the daily cap. First, the day must be the user's local calendar day; keying on the UTC date charges an East-Asian user's 8am message to yesterday's budget. Second, increment first and check the returned value, then give the slot back if it exceeded — a read-then-write races, letting two candidates read the same count and both go out. Return the slot on a hard rejection from the channel as well.
- Finish with the architecture half, which is the valuable part: all three gates must run before the content is generated, not at the send step. Get the order wrong and the program still works and sends the same messages; the only difference is that you paid for a model call on every message you then threw away. At 1000 users, three candidates each per day, forty percent blocked and roughly $0.00075 per message, that is about $27 a month wasted — more than the normal conversational spend for the same cohort — and it is invisible in monitoring. Only putting candidate count next to sent count reveals the gap.
- Expect: what order do the three gates run in? Timezone, quiet hours, daily cap, with the cap last. A message deferred to tomorrow morning must not consume today's quota; reverse the order and users get rate-limited despite having received almost nothing.
分析过程 · 先想清楚再作答
- 这题有两个题眼,很多人只答了前一个。第一个是「怎么判断」(细节题),第二个是「放在哪一步」(架构题),后者才是拿分点。
- 先讲 quiet hours 的判断。把时刻折成从午夜起算的分钟数之后,绝大多数人第一次都会写成 start 小于等于 now 且 now 小于 end。这对午休那种同日区间是对的,对 22:00 到 08:00 恒为 false——start 是 1320、end 是 480,条件永远不成立,于是半夜照发。正确写法是 start 小于 end 时用「且」,start 大于 end(跨午夜)时换成「或」。这个 bug 恶劣在只在跨午夜的配置上错,用 13:00 到 14:00 写的单元测试全绿。
- 接着是命中之后怎么办:推迟,不是丢弃。判据不该由发送方临时决定,而应该由消息自己带一个过期时刻——过期时刻早于窗口结束的丢弃,其余一律推迟到窗口结束。限时取消提醒过了今晚就没意义,账单提醒明早发一样有效。另外要提一句惊群:所有推迟的消息会算出同一个到期时刻,要加一个按用户标识哈希得出的抖动(不能用随机数,否则线上复现不了)。
- 再讲每日上限的两个细节。一是「一天」必须是**用户本地日历日**,写成 UTC 日的话东八区用户早上八点前发的会算进昨天的额度。二是必须先占坑再判断——原子自增拿返回值比上限,超了再把名额还回去;先查后写在并发下两条候选会同时读到同一个值然后一起发出去。渠道明确拒绝时也要把名额还回去。
- 最后是架构题那一半,也是最值钱的一段:三道闸必须在**生成内容之前**判断,不是在发送那一步。顺序错了程序照样跑通、发出的消息也一样,唯一区别是每条被拦下的消息你都已经付过一次模型调用的钱。按 1000 用户每天各 3 条候选、拦掉四成、单条约 0.00075 美元算,一个月白花约 27 美元,比这批用户的正常对话开销还高,而且监控上完全看不出来——只有把候选数和实际发送数并排摆出来才看得见差额。
- 可以预期的追问:三道闸内部谁先谁后?答时区、安静时段、每日上限,上限必须最后。因为被安静时段推迟的消息明早才发,不该占掉今天的名额;顺序反了用户会发现自己明明没收到几条却被限流了。
Key points
- Cross-midnight quiet hours need or when start is after end; the naive and version is always false for 22:00-08:00
- On a hit, defer to the end of the window rather than drop; only drop when the message's own expiry precedes that
- Deferral causes a thundering herd, so add jitter hashed from the user id, never a random value
- The day in a daily cap must be the user's local calendar day, not the UTC date
- Increment atomically then compare and release on overflow; read-then-write over-sends under concurrency
- Run all three gates before generating content — otherwise every blocked message has already been paid for (about $27/month at the example scale); order them timezone, quiet hours, daily cap, with the cap last
答题要点
- 跨午夜的安静时段:start 小于 end 用「且」,start 大于 end 换成「或」,朴素写法对 22:00-08:00 恒为 false
- 命中安静时段是推迟到窗口结束而不是丢弃;只有自带的过期时刻早于窗口结束才丢
- 推迟会造成惊群,要加按用户标识哈希得出的抖动,不能用随机数
- 每日上限的「天」必须是用户本地日历日,不是 UTC 日
- 计数要先占坑再判断(原子自增后比上限,超了还回去),先查后写在并发下会超发
- 三道闸必须在生成内容之前判断,装晚了每条被拦的消息都已经付过模型调用的钱(示例量级约 27 美元每月);闸内顺序是时区、安静时段、每日上限,上限最后
You have abstracted model calls, payments and notification channels behind providers. How does the notification interface differ from the other two?模型调用、支付、通知渠道你都做过 provider 抽象。通知这一份接口和另外两份有什么不同?
Common in ChinaCommon overseasIntermediate#provider-abstraction#api-design#retry-semanticsHow to reason about it · think before answering
- This question separates applying a pattern from understanding one. Saying all three are the same — an interface with several implementations so you can swap vendors without touching business code — only covers the shared part; the interviewer wants to see whether you spotted the differences and encoded them in the interface.
- Acknowledge the commonality in one line: each pushes a replaceable dependency behind an interface, business code depends only on the interface, and the selection point lives in exactly one place. Correct, but not differentiating.
- Then give three differences, which is where the points are. First, accepted is not delivered: when a payment gateway returns success the money has moved, but when a notification channel returns success it has merely taken the message, and actual delivery arrives later as an asynchronous receipt. So the result is accepted, never delivered, and it must carry the provider-side message id so the receipt can be correlated.
- Second, throttling lives at a different layer: the channel has its own per-second ceiling and tells you to come back later with a 429 plus a retry interval — a channel-level technical constraint — while the daily cap is a user-level courtesy constraint. Collapsing them into one concept makes them impossible to tune separately: one says this line is congested, the other says this person has been interrupted enough today.
- Third, there is no undo: payments have refunds, notifications do not. Once handed to the channel the message is gone, and cancel only means anything before that handoff. So the interface must not expose a cancel method — leaving an operation that cannot work is worse than not having it, because callers will actually use it.
- Expect: how do you design retries then? Three classes. Throttling backs off for the interval the channel gave you. Parameter errors (invalid body, unsubscribed user) are not retryable, so give up and return the daily slot. Server errors and timeouts are retryable but must carry the same idempotency key — you can delete a duplicate row, you cannot un-buzz a phone. Add a test for the abstraction itself: if a new channel only has to implement send the message, the boundary is right; if it also needs to know whether it is quiet hours or which message of the day this is, business rules have leaked into the channel layer.
分析过程 · 先想清楚再作答
- 这题在考你是「会套模式」还是「懂模式」。把三者说成一回事——都是接口加多个实现、换厂商不改业务——只答到了共性那一层,面试官真正想看的是你有没有识别出差异并把它写进接口。
- 先给共性,一句话带过:都是把「会被替换的东西」推到接口后面,业务代码只认接口,选择点集中在一处。这一层是对的,但不构成区分度。
- 然后给三条差异,这是拿分点。第一,收下不等于送达:支付网关返回成功钱就划走了,通知渠道返回成功只表示它收下了,真正送达是过一会儿通过回执异步告诉你的。所以返回值只能叫 accepted 不能叫 delivered,而且必须带渠道侧的消息标识,回执回来时靠它对上号。
- 第二,限流的层次不同:渠道自带每秒条数上限并会用 429 加重试间隔告诉你稍后再来,这是**渠道维度的技术约束**;而每日发送上限是**用户维度的礼貌约束**。两者混成一个概念就没法分别调整——一个说的是这条线路挤不下了,一个说的是这个人今天已经被打扰够了。
- 第三,没有撤销:支付有退款,通知发出去就撤不回来,取消只在交给渠道之前有效。所以接口里不能出现 cancel——在接口上留一个做不到的操作比根本没有这个操作更危险,调用方会真的去用它。
- 可以预期的追问:那失败重试怎么设计?答分三类:限流按渠道给的时长退避重试;参数错(正文非法、用户已退订)不可重试,直接放弃并把当天的名额还回去;服务端错误或超时可重试但必须带同一个幂等键——数据库里多一行你能删掉,用户手机上多响一声删不掉。再补一条判断抽象好坏的判据:新接一个渠道时如果它只需要实现「把这条消息发出去」,抽象就对了;如果它还得知道现在是不是安静时段、这是今天第几条,说明业务规则泄进了渠道层。
Key points
- The shared part is pushing a replaceable dependency behind an interface with a single selection point — that is only the baseline
- Accepted is not delivered: name the result accepted and carry a provider message id so async receipts can be correlated
- Throttling has two layers: the channel's per-second ceiling is technical, the daily cap is a user-level courtesy rule, and they must stay separate
- Notifications have no undo, so the interface must not expose cancel — an unimplementable operation is worse than none
- Three retry classes: back off for the channel's interval on throttling, give up and release the slot on parameter errors, retry server errors with the same idempotency key
- Test the boundary: a new channel should only implement send; needing to know quiet hours or today's count means business rules leaked into the channel
答题要点
- 共性是把可替换依赖推到接口后面、选择点集中一处,但这只是及格线
- 收下不等于送达:返回值叫 accepted 不叫 delivered,必须带渠道侧消息标识以便异步回执对号
- 限流分两层:渠道的每秒上限是技术约束,每日发送上限是用户维度的礼貌约束,不能合并
- 通知没有撤销,接口里不能有 cancel;留一个做不到的操作比没有更危险
- 重试分三类:限流按渠道给的时长退避、参数错不可重试并归还名额、服务端错误可重试但必须带同一个幂等键
- 判断抽象切没切对:新渠道只需实现发送就对了,还要知道安静时段和当天条数就说明业务泄进了渠道层