Building a REST API for Crew Rest Requirements
Modern flight operations demand deterministic, auditable validation of crew rest before any schedule is published or modified. Manual reconciliation across spreadsheets and legacy dispatch systems introduces unacceptable latency, human error, and regulatory exposure. This guide walks through building a purpose-built REST endpoint that turns ad-hoc rest checks into a programmatic gate: it ingests a pair of consecutive duty boundaries, applies jurisdiction-specific thresholds, and returns an actionable compliance state a pairing engine can act on without human intervention. The endpoint is a thin, synchronous surface over the canonical duty states produced by the crew duty time taxonomy layer, and it belongs to the wider Core Architecture & Regulatory Mapping domain that translates legal text into executable logic.
The exact validation task this endpoint solves
The scoped problem is narrow and precise: given the UTC end instant of one duty and the UTC start instant of the next, confirm that the gap between them satisfies the applicable minimum rest, and return a structured verdict a scheduling engine can block or flag on. The difficulty is not the subtraction — it is that the “applicable minimum” is not a constant. Under the FAA Part 117 rule schema, the baseline is 10 consecutive hours (§117.25), but reduced-rest provisions can lower the floor, split-duty provisions (§117.15) change what counts as rest at all, and carrier buffer policies add margin on top. For crews under the EASA FTL compliance framework, the same request must resolve against ORO.FTL.235 minimums instead. The endpoint must therefore be a pure function of a normalized request plus a small, versioned set of regulatory constants — never local datetime arithmetic scattered through a scheduling UI.
Prerequisites
- Python 3.11 or newer (required for
zoneinfoandStrEnum) - FastAPI 0.110+ and Pydantic v2 for routing and boundary validation
- The
tzdatapackage on any host without a system IANA time-zone database -
pytest8.x andhypothesis6.x for the verification suite - Read access to the classified duty-period feed emitted by the taxonomy layer
- Current regulatory constants on hand: §117.25 (10 h rest), §117.15 (split duty), and
ORO.FTL.235
Step-by-step implementation
Step 1 — Model the request and response at the boundary
Validate untrusted input before it can reach the calculator. Naive (offset-free) timestamps are rejected outright, because a missing offset is the single most common source of cross-jurisdictional miscalculation. The verifiable output of this step is that a request carrying a naive datetime raises a ValidationError instead of silently proceeding.
from datetime import datetime
from enum import StrEnum
from zoneinfo import ZoneInfo
from pydantic import BaseModel, field_validator
class Jurisdiction(StrEnum):
FAA = "faa"
EASA = "easa"
class RestCheckRequest(BaseModel):
crew_id: str
prev_duty_end: datetime
next_duty_start: datetime
rest_facility_tz: str # IANA identifier, e.g. "America/New_York"
jurisdiction: Jurisdiction = Jurisdiction.FAA
reduced_rest: bool = False
@field_validator("prev_duty_end", "next_duty_start")
@classmethod
def require_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None or v.utcoffset() is None:
raise ValueError("timestamps must be timezone-aware")
return v.astimezone(ZoneInfo("UTC"))
@field_validator("rest_facility_tz")
@classmethod
def known_zone(cls, v: str) -> str:
ZoneInfo(v) # raises if the IANA key is unknown
return v
class RestCheckResult(BaseModel):
crew_id: str
compliant: bool
rest_hours: float
required_hours: float
violations: list[str]
Step 2 — Write the rest calculation as a pure function
Isolating the logic into a stateless function that accepts a validated request and returns a structured result makes it deterministic, replayable during audit, and trivially unit-testable. The regulatory floors live in named constants so a revision or a contractual buffer can be diffed and rolled back without touching control flow. The verifiable output is a RestCheckResult whose rest_hours equals the real elapsed interval and whose violations list is empty for a compliant pairing.
FAA_MIN_REST_H = 10.0
FAA_REDUCED_MIN_REST_H = 9.0
EASA_MIN_REST_H = 10.0
def required_rest_hours(req: RestCheckRequest) -> float:
if req.jurisdiction is Jurisdiction.FAA:
return FAA_REDUCED_MIN_REST_H if req.reduced_rest else FAA_MIN_REST_H
return EASA_MIN_REST_H
def evaluate_rest(req: RestCheckRequest, buffer_h: float = 0.0) -> RestCheckResult:
delta = req.next_duty_start - req.prev_duty_end
rest_hours = delta.total_seconds() / 3600.0
required = required_rest_hours(req) + buffer_h
violations: list[str] = []
if rest_hours < 0:
violations.append("next duty starts before previous duty ends")
if rest_hours < required:
violations.append(f"rest {rest_hours:.2f}h below required {required:.2f}h")
return RestCheckResult(
crew_id=req.crew_id,
compliant=not violations,
rest_hours=round(rest_hours, 2),
required_hours=required,
violations=violations,
)
Because both boundaries are normalized to UTC before the subtraction, rest_hours is the true elapsed duration in real hours — correct even when the rest window straddles a daylight-saving transition in rest_facility_tz. The facility zone is retained for report-time-keyed rules (the §117.13 FDP table row) that must be computed in local time, and is never mixed into this elapsed-time arithmetic.
Step 3 — Expose the endpoint and make the buffer tunable
A single POST route provides the synchronous validation gate. The compliance buffer — margin for ground handling, taxi, and crew transport variability — is read from an environment variable so flight ops managers can adjust strictness without redeploying the service. The verifiable output is a running service that returns 200 with a JSON verdict for valid input and 422 for malformed payloads.
import os
from fastapi import FastAPI, HTTPException
app = FastAPI(title="Crew Rest Compliance API")
REST_BUFFER_H = float(os.getenv("REST_BUFFER_HOURS", "0.0"))
@app.post("/v1/rest-checks", response_model=RestCheckResult)
def check_rest(req: RestCheckRequest) -> RestCheckResult:
try:
return evaluate_rest(req, buffer_h=REST_BUFFER_H)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
The scheduler submits duty boundaries, the service normalizes and validates them, the pure calculator returns a verdict, and the pairing engine blocks or flags the assignment automatically:
Figure: Synchronous validation gate — the scheduler submits duty boundaries and receives a deterministic verdict the pairing engine can act on programmatically.
Verifying the endpoint
Assert the happy path and the boundary directly against the pure function, then extend to property-based tests that fuzz the interval. A ten-hour UTC gap under FAA rules must be compliant; a nine-hour-59-minute gap must not.
from datetime import datetime, timezone
def test_ten_hour_rest_is_compliant():
req = RestCheckRequest(
crew_id="C123",
prev_duty_end=datetime(2026, 3, 1, 22, 0, tzinfo=timezone.utc),
next_duty_start=datetime(2026, 3, 2, 8, 0, tzinfo=timezone.utc),
rest_facility_tz="America/New_York",
)
result = evaluate_rest(req)
assert result.compliant is True
assert result.rest_hours == 10.0
assert result.violations == []
Once verdicts are persisted for audit, reconcile them in the warehouse. This query surfaces any crew member with a logged rest breach in the last 24 hours, which should match the alerts fired to dispatch:
SELECT crew_id,
COUNT(*) FILTER (WHERE NOT compliant) AS breach_count
FROM rest_check_log
WHERE checked_at >= now() - INTERVAL '24 hours'
GROUP BY crew_id
HAVING COUNT(*) FILTER (WHERE NOT compliant) > 0
ORDER BY breach_count DESC;
Failure modes and troubleshooting
- Naive timestamp accepted or rejected as
422. A payload timestamp without an offset (2026-03-01T22:00:00) trips therequire_awarevalidator. This is intended: fix the caller to emit ISO-8601 with an offset or aZsuffix, never a bare local time. ZoneInfoNotFoundErroronrest_facility_tz. The host lacks the IANA database, so a valid key likeEurope/Berlinfails. Install thetzdatapackage into the service environment; do not fall back to a hardcoded offset, which breaks across DST.- Negative
rest_hours. Overlapping or mis-ordered duty boundaries from upstream produce a negative interval. The calculator flags it as a violation rather than a valid short rest — treat it as a data-quality alert against the ingestion feed, not a compliance decision. - Report-time drift across a DST transition. Elapsed rest is correct in UTC, but the §117.13 FDP table lookup keyed on local report time can shift a row if computed in the wrong zone. Always resolve report-time rules in
rest_facility_tz, separately from the elapsed-rest subtraction. - Reduced rest passing without compensatory rest. Setting
reduced_rest=Truelowers the floor to 9 hours but says nothing about the compensatory rest §117.25 requires afterward. Gate the flag behind a check that a compensatory period is scheduled at the earliest opportunity, or the endpoint will approve an under-rested pairing.
FAQ
Does the endpoint measure rest in UTC or local time?
The elapsed rest duration is computed in UTC, because a fixed interval is the same number of real hours regardless of clock. Local time (rest_facility_tz) is retained only for rules whose thresholds are keyed on the local report time, such as the FDP table row, and those are evaluated separately from the rest subtraction.
How do I handle a split-duty rest under §117.15?
A split-duty rest happens inside a flight duty period, so it is not a gap between two duties and should not be sent to this endpoint as prev_duty_end/next_duty_start. Model it as a distinct in-duty rest segment in the taxonomy layer, credit it against the duty limit there, and call this endpoint only for the between-duty rest window.
What happens across a daylight-saving transition?
Nothing special is required for the duration check: converting both boundaries to UTC before subtracting yields the true elapsed hours even when the local window is nominally ten hours but really nine or eleven. Only local-time-keyed lookups need the transition-aware conversion, which is why rest_facility_tz is a required field.
Should reduced rest below nine hours ever pass?
No. Nine hours is the FAA floor for a reduced rest period; the calculator will not return compliant for anything shorter, and the buffer only ever raises the required minimum, never lowers it. Any request implying rest below the floor should be blocked and routed to a documented exception workflow.
How does this differ for EASA-regulated crews?
Set jurisdiction="easa" and the endpoint applies the ORO.FTL.235 minimum instead of the Part 117 figure. The request shape is identical; only the constant selected by required_rest_hours changes, which is exactly why the taxonomy routes the same normalized duty history to either schema without duplicating the state machine.
Related
- Crew Duty Time Taxonomy Mapping — the parent layer that produces the canonical duty states this endpoint validates.
- FAA Part 117 Rule Schema Design — the schema that supplies the §117.25 and §117.15 constants.
- EASA FTL Compliance Frameworks — the European rule set the same request resolves against.
- Rest Period Compliance Checks — the deeper rule-engine treatment of split-rest and weekly-rest validation.
- Flight Data Ingestion & System Sync — the upstream pipeline that delivers the UTC-stamped duty boundaries this endpoint consumes.
Back to Crew Duty Time Taxonomy Mapping.