48 小時探測筆記:記錄請求間隔,判讀免費伺服器首次請求延遲
48-Hour Field Notes: My Free Box Looked Slow Until I Logged the Gap Between Requests
Taylor Wang 分享一套 48 小時探測方法,逐次記錄前次請求後的閒置時間,以判讀免費伺服器閒置後首次請求的延遲。探測每次只發出一個請求、不在探測流程內重試,並把慢速成功、HTTP 5xx、連線錯誤及 4xx 分開分類;文中示例數據為合成內容,並非實測結果。作者提醒,頻密探測可能令伺服器保持温熱,建議比較 5 分鐘與 30 分鐘的探測間隔。
Everyone says the same sentence when a cheap box misbehaves: "the first request after a quiet night takes forever." But is that one bug, or is it three different bugs wearing the same coat, and would you even be able to tell them apart with the logs you keep today? My first attempt at answering that produced a nice-looking chart and a completely wrong conclusion, because I never recorded how long the box had been idle before each request.
So these are field notes from a 48-hour probe I built around a single question: what does the first request actually cost, and how would I know? Everything below is the harness and the decision rules, not a benchmark.
Why "it felt slow" is not a diagnosis
A slow-but-successful response, a 502 from a proxy, and a connection that never opens are three different worlds. The first one means the work happened; the second means something in front of the work was not ready; the third means you never reached anything at all. Retrying all three the same way is how people end up blaming a rate limit for a retry storm they wrote themselves, which is a lesson I already paid for once.
Ask yourself what your current logs would tell you if I asked: how many seconds had passed since the previous request? If the answer is "nothing", your latency numbers are mixing two populations of requests and averaging them into noise.
The 48-hour plan
- Pick the one route your job actually calls first — not
/health, not a TCP ping. - Probe it on a fixed interval, and record the gap since the previous probe in every record.
- Never retry inside the probe. One attempt, one line, done — the probe measures, the job decides.
- Write append-only JSONL so a crash costs you one line rather than the file.
Step 2 is the whole trick, and it is the step I skipped on the first run.
The prober
# probe_idle.py — one attempt per invocation, driven by cron or a systemd timer
import json, os, time, datetime, pathlib, urllib.request, urllib.error
ENDPOINT = os.environ["PROBE_URL"] # the same first route your job hits
LOG = pathlib.Path(os.environ.get("PROBE_LOG", "probe.jsonl"))
def now_iso():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def last_ts():
if not LOG.exists():
return None
with LOG.open("rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - 4096))
tail = f.read().decode("utf-8", "ignore").strip().splitlines()
return json.loads(tail[-1])["ts"] if tail else None
def probe():
t0 = time.perf_counter()
status, err = None, None
try:
req = urllib.request.Request(ENDPOINT, headers={"User-Agent": "idle-probe/1"})
with urllib.request.urlopen(req, timeout=20) as r:
status = r.status
r.read(1) # read a byte so "headers only" is not the measurement
except urllib.error.HTTPError as e:
status = e.code
except Exception as e:
err = type(e).__name__
return status, err, round((time.perf_counter() - t0) * 1000)
if __name__ == "__main__":
ts, prev = now_iso(), last_ts()
status, err, ms = probe()
gap = None
if prev:
gap = round((datetime.datetime.fromisoformat(ts)
- datetime.datetime.fromisoformat(prev)).total_seconds())
rec = {"ts": ts, "gap_s": gap, "ms": ms, "status": status, "err": err}
with LOG.open("a") as f:
f.write(json.dumps(rec) + "\n")
print(rec)
Run it every 5 minutes for the first day, then every 30 minutes, and compare the two days. Yes, changing the interval is a deliberate perturbation of the system you are observing — hold that thought, it becomes a problem in a minute.
Illustrative output (synthetic, not measured)
The lines below are made-up examples that show the shape of the data; substitute your own run before drawing any conclusion:
{"ts": "2026-10-25T02:00:04+00:00", "gap_s": null, "ms": 1840, "status": 200, "err": null}
{"ts": "2026-10-25T02:05:05+00:00", "gap_s": 301, "ms": 190, "status": 200, "err": null}
{"ts": "2026-10-25T09:30:11+00:00", "gap_s": 15906, "ms": 7310, "status": 200, "err": null}
{"ts": "2026-10-25T09:35:12+00:00", "gap_s": 301, "ms": 205, "status": 200, "err": null}
{"ts": "2026-10-25T14:00:03+00:00", "gap_s": 15891, "ms": 20, "status": 502, "err": null}
Classifying before you conclude
Latency alone cannot separate the three failure kinds, so classify each record first and only then summarise.
import json, statistics as st
FAST_MS = 1500
def cls(r):
if r["err"]:
return "conn_error" # timeout, refused, DNS
if r["status"] >= 500:
return "http_5xx" # reached something, it was not ready
if r["status"] >= 400:
return "http_4xx" # your request is the problem, not the box
return "ok" if r["ms"] < FAST_MS else "ok_slow"
rows = [json.loads(l) for l in open("probe.jsonl")]
for r in rows:
r["cls"] = cls(r)
cold = [r for r in rows if r["gap_s"] and r["gap_s"] > 900]
warm = [r for r in rows if r["gap_s"] and r["gap_s"] <= 300]
for name, bucket in (("cold", cold), ("warm", warm)):
ok = [r["ms"] for r in bucket if r["cls"].startswith("ok")]
print(name, "n=", len(bucket),
"ok=", len(ok),
"p50=", st.median(ok) if ok else None,
"fail=", sum(1 for r in bucket if not r["cls"].startswith("ok")))
If cold and warm look like different systems, congratulations — you have two populations, and your monthly average was hiding both of them.
The decision table I actually wanted
| Observed class | What it usually means | Probe action | Job action |
|---|---|---|---|
ok_slow on first request, fast after |
work is being done lazily on the cold path | record it, do not retry | warm once, then run |
http_5xx on first request |
something in front of the app is not ready | record it, do not retry | back off, then one warm-up, then run |
conn_error (timeout/refused) |
nothing accepted the connection | record the gap | bounded retry with jitter, then fail loudly |
4xx |
your own request shape | stop probing | fix the client |
Notice the probe never retries in any row. The probe's job is to make the world legible; the job's job is to survive it.
What broke, and what I would repeat
- The keepalive confound. Probing every five minutes may keep the box warm, which means you can no longer observe the thing you set out to measure. I would run a second, sparse phase at 30-minute intervals and compare, rather than trusting a single cadence.
-
A missing covariate beat me on day one. Without
gap_s, a 7-second response and a 7-second response after nine hours of silence are indistinguishable rows — one number, two stories. -
/healthlied by being easy. A static health route can be warm while the real first route is still doing lazy setup; probe the route your job calls, not the one that is cheapest. - Warm-up before the job, not around the clock. A 24/7 keepalive buys you a permanently busy box and hides the cold-path failure your users will still hit at 3 a.m.
I would repeat the append-only JSONL, the no-retry probe, and the cold/warm split. I would not repeat the single-cadence run, and I would add one column for the response's Content-Length so a truncated body shows up as a number instead of a mystery.
If your first request after idle is a model call, the cold path includes the provider too, so the probe has to hit a model-backed route to be honest about it. That is the one place MonkeyCode's free model access and free server option fit here — they are operator-supplied availability claims, useful for running a 48-hour probe without a paid box while you are still deciding, not measured facts in this article. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Who should not use this
Skip this if you have real server-side latency histograms already — your tracing backend answers the cold/warm question better than a cron job does. Skip it if your traffic is steady enough that idle gaps barely exist, and skip it if a wrong guess costs nothing, because 48 hours of probing is not free either. And do not turn a two-day sample into a promise about permanence; a probe measures your box, on your days, against your route.
What I want from you is not agreement but a number: how many seconds passed since the previous request, and does your log know?
If you want to run the two-phase version before spending anything, the free server option is the cheapest place to park the timer — bring your own probe.jsonl and let the gap column argue with you.
來源:DEV Community · dev.to